How to store numeric form input in the model?

I’m keen to understand how other people tackle this one, and if my solution contains any gotchas.

When entering numeric values in input boxes, the box needs a String, but the consuming function(s) needs an Int or a Float. So what to store in the model?

When the input must be an integer, converting to an integer and then returning it to the box as a string works fine. But when it’s a float, there’s a problem which prevents decimal input:

Input string    String.toFloat  
     1               1
     12              12
     12.             12
     12.3            123 

This might not be a problem if the input string is ‘processed’ only when focus is lost, but it’s [nicer|better|sometimes-required] to process and consume input character-by-character.

Storing the value in the model as a String, and then converting to Float when needed, solves the problem. But we lose any benefit of type checking.

My first try at a solution was:

type NumericString
  = NumericString NumType String

type NumType
  = Int
  | Float 

So we store the String and record what it represents (Int or Float). A consuming function can then check NumType to ensure that the input is of the correct type.

But this can’t be type-checked by the compiler.

However, this can:

type IntString
   = IntString String

type FloatString
   = FloatString String

The immediate consuming functions would be those that convert these new types to numeric values:

intStringToInt IntString -> Maybe Int
intStringToInt intString =
   let
      (IntString string) = intString
   in
      String.toInt string

and

floatStringToInt FloatString -> Maybe Float
floatStringToInt floatString =
   let
      (FloatString string) = floatString
   in
      String.toFloat string

And, of course, the equivalent for extracting the string to go back in the text box.

1 Like

Just as a quick note, you can deconstruct your value as the argument, rather than doing it in a let like that, i.e:

intStringToInt IntString -> Maybe Int
intStringToInt (IntString string) =
      String.toInt string
2 Likes

I think it all depends! For simple integers, you could simply prevent the user from typing/pasting in anything that is not an integer.

But what about this:

type TypedInt 
  = Unvalidated String
  | Validated Int

if you have that in your model, you can track the state and be careful where you need that validated value.

type TypedInt 
  = Unvalidated String
  | Validated Int

I recommend against this approach. If the user types in 01 then you’ll have Validated 1 which, when shown in the number field, will appear as 1 causing the user’s cursor to jump. It’s better to just have a string in your model and try validating it when you need a float or int.

4 Likes

We’ve run into similar issues (with Date inputs), and the general tradeoff seems to be either:

  • A) Use a type on the Elm side that’s “wide” enough to capture all user inputs, including incomplete values (usually String), then keep the element state and Elm model tightly in sync (perhaps with onInput), or…

  • B) Use a type on the Elm side that’s more restrictive (and typically closer to your “business model”), then only get a subset of changes back into the Elm model (maybe using onBlur).

(A) is more code in Elm, but it has the advantage of preserving the user’s input ― avoiding the “jumping cursor”, and keeping the user’s input around when Elm redraws the DOM.

In general, our code seems clearer (and less buggy) when there’s a clear separation between what the user’s been typing, and what we’re treating as our “Domain model”.

tl;dr ― go with (A), use temporary Strings for user input state, have one function that converts that temporary state into the restrictive “real” state once they’re done.

Maybe this can give you some inspiration on how to build number forms and avoiding the string problem.

1 Like

We can have values as Strings in a temporary record and validate it in view for example, rendering all errors. We have got simple record update functions, simple validation functions. And when all validation passed transform it to our real record with Ints and Floats with help of transformation function

Otherwise we can for example validate values in onInput and save it in a model as not String but Int or Float plus result of validation. Then we have to save our validation result so

 type alias ValidationErr = String
 type alias IntValue = Result ValidationErr Int
 type alias FloatValue = Result ValidationErr Float

So onInput validates, transforms to Int or Float and transfer IntValue or FloatValue to update function

@AlanQ I think you’re going down the right path, storing the “raw” user input string on the model and then applying some parsing/validation logic on top.This may mean having two distinct types for your form and your domain model.

-- store this on your model
type alias Form =
  { age : String
  , cost : String
  }

-- use this in your domain logic
type alias Cheese =
  { age : Int
  , cost : Float
  }

Then you just need a function to convert between the two. Typically this will involve some sort of mapN function:

cheeseFromForm : Form -> Maybe Cheese
cheeseFromForm form =
  Maybe.map2 Cheese
    (String.toInt form.age)
    (String.toFloat form.cost)

In the forms you can show errors like:

view : Form -> Html Msg
view form =
  div []
    [ validationText String.toFloat form.age -- show user-friendly error if input is invalid
    , input [ onInput AgeChanged, value form.age ] []
    , ...
    ]

You can expand on this pattern to get really fancy. For example, your parsing functions might return a Result or Validatable a type to allow you more nuance in displaying errors to users.

Taking it even further, you might wrap both the raw value and a parsing function into a Field a type and compose forms out of fields like the composable-form library does.

5 Likes

Many thanks for all the replies.


Cheers @Latty, that’s much neater. Elm’s elegant type deconstruction.


@berend

For simple integers, you could simply prevent the user from typing/pasting in anything that is not an integer.

Integers are easy. It’s Floats that cause the problems :wink:

Although MartinS makes a good point about leading zeros.

My stumbling point was dangling decimal points.
12. is a valid float, but loses it’s . when converted.


@MartinS

It’s better to just have a string in your model and try validating it when you need a float or int.

Agreed. But I need to validate (and use) the input character by character.
And if you wait til you need the value, it might be too late to highlight the user’s error to the user.

By using my method, 01 (and 12.) validates just fine after each character and it can be kept in the model as it is (so no cursor jump).


@rkb Yes, date inputs are more awkward than they at first seem.

tl;dr ― go with (A), use temporary Strings for user input state, have one function that converts that temporary state into the restrictive “real” state once they’re done.

I thought about this approach and played with it.
What found I needed was:

Both onChange — to do the char-by-char validation — and onLoseFocus — to do the final conversion of temporary state into ‘real’ state. (I’m using Elm-UI)
Plus either
One value in the model just for holding temporary string input — which is risky as you must clear it down when the user starts input in another box (onFocus/onLoseFocus).
Or
To have both a String (temporary) and a numeric (‘real’) in the model for each input — which is also risky as they can become out of sync.


@albertdahlin Brilliant.
The article ends

Let’s all just hope the kind [of] people who make real forms don’t get any ideas.

Too late… :wink:
Actually, there’s some very clever thought gone in to those ‘ideas’ — a good teaching tool.


@Dmitry_Yakimov

That’s an interesting idea, to validate using a function that returns a Result.
JoelQ mentions a couple of packages seem to do that.


@joelq

Some nice ideas there. Thank you. When my forms get complex, the packages you mention look good.

The form that prompted the question has pre-filled valid input and is validated/corrected character-by-character. I favour this approach — stopping the user entering invalid input at all — rather than flagging up an error. It will be made all the easier when I get my head around the parser package.

My concern with having both Form data and Model/Domain (Cheese) data is that they can get out of sync.

Maybe.maping so we only get a result when all values are valid is neat.


This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.