Why?
I was thinking about the weaknesses of records constructors and the APIs that use them, for example decoders, parser and generators:
- Records constructors depend on fields order. Changing the order of fields in the type alias may break silently code using its constructor if some fields had the same type.
- Fields are not explicit using the constructor. This forces to look at the type alias to know which is which, and make APIs using records constructors look a little magical.
Those flaws are well known, and previous experiments to improve this have often been based on using some kind of do-notation, for example rtfeldman/elm-json-experiment, discussed in this post.
But the root cause of these issues is actually the signature of records type aliases constructors, which do not seem to have been discussed much since their initial introduction discussion (please tell me if you find something else).
So let’s try to improve those to see where this takes us.
How?
Safer records constructors
Currently, for the following record:
type alias Point = { x : Int, y : Int }
We have the following constructor defined automatically:
Point : Int -> Int -> Point
What if instead the constructor was:
Point : { x : Int } -> { y : Int } -> Point
You can think of it as merging single-field records together to get the whole one.
This would prevent using expressions like Point 0 0
, but this is not a bad thing as arguments order is not safe for fields of the same type, fields are not explicit, and I suspect that { x = 0, y = 0 }
performance is better.
Improved constructor signature and error messages
First, this new constructor signature allows to understand the record built without looking at the record type alias. For example in elm repl
:
> Point
<function> : { x : Int } -> { y : Int } -> Point
Also partially applied constructors are more explicit and safer:
> Point { x = 0 }
<function> : { y : Int } -> Point
Swapping by mistake the arguments can today lead to a logical error without any compiler error if the fields have the same type.
With this new constructor signature, there would be a compilation error, with a very useful message:
-- TYPE MISMATCH -------------------------------------------------- src/Main.elm
The 1st argument to `Point` is not what I expect:
24| Point { y = 0 } { x = 0 }
^^^^^^^^^
This argument is a record of type:
{ y : number }
But `Point` needs the 1st argument to be:
{ x : Int }
Hint: Seems like a record field typo. Maybe y should be x?
Safer and explicit mapN
and pipeline APIs:
This constructor would be used with Json.Decode
like:
import Json.Decode exposing (..)
map2 Point
(map (\x -> { x = x }) (field "x" int))
(map (\y -> { y = y }) (field "y" int))
This could be more readable and there is a remaining repetition, but already:
- Fields order cannot be wrong
- Fields are explicit
Record singleton constructor for improved readability and no repetition
As record singletons would be used a lot, it would make sense to have syntactic suggar
to create them. Mirroring the current .field
one, we could have:
{ field }
which would have the signature a -> { field : a }
It seems logical to me: { field = 2 }
creates a { field : Int }
, so { field }
needs a missing argument to create a record. I believe it would not add too much complication for the compiler parser either, and would fit naturally in the Elm language syntax.
We would get some readable and robust APIs:
map2 Point
(map { x } (field "x" int))
(map { y } (field "y" int))
Everything is now important, this is more apparent for example when decoding an array:
map2 Point
(map { x } (index 0 int))
(map { y } (index 1 int))
It’s a little more verbose than current API, but I think its explicitness would make it easier to understand by beginners and would look less magic.
Maybe one drawback is that newcomers could confuse the syntax with records pattern matching, but from my experience, they rarely know or use the pattern matching syntax anyway.
Using the current json-decoder-pipeline
API:
succeed Point
|> required "x" (map { x } int)
|> required "y" (map { y } int)
The only verbose thing is the map
and associated parentheses, but updated packages APIs could add functions to improve this if this is really an issue, maybe for example with something like:
map2 Point
(mapField "x" { x } int)
(mapField "y" { y } int)
or maybe
map2To Point
({ x }, field "x" int)
({ y }, field "y" int)
or in pipeline:
succeed Point
|> mapRequired "x" { x } int
|> mapRequired "y" { y } int
These examples could most likely be improved, but this gives an idea.
Resolvers from json-decode-pipeline would also be more robust, preventing fields order errors:
type alias User =
{ id : Int
, email : String
}
userDecoder : Decoder User
userDecoder =
let
toDecoder : { id : Int } -> { email : String } -> Int -> Decoder User
toDecoder id email version =
if version > 2 then
Decode.succeed (User id email)
else
fail "This JSON is from a deprecated source. Please upgrade!"
in
Decode.succeed toDecoder
|> required "id" (map { id } int)
|> required "email" (map { email } string)
|> required "version" int
|> resolve
A singleton record can even be used for version
, just in case other parameters are added later:
userDecoder : Decoder User
userDecoder =
let
toDecoder : { id : Int } -> { email : String } -> { version : Int } -> Decoder User
toDecoder id email { version } =
if version > 2 then
Decode.succeed (User id email)
else
fail "This JSON is from a deprecated source. Please upgrade!"
in
Decode.succeed toDecoder
|> required "id" (map { id } int)
|> required "email" (map { email } string)
|> required "version" (map { version } int)
|> resolve
The single-field record pattern
Record singletons are quite safe parameters types, between scalar and opaque types, and they do not require a declaration like the latter. They can be used more generally as a pattern for named arguments, if removing ambiguity is needed.
Anonymous records constructors
It makes sense that the single-field constructor syntax would work with several fields,
compensating the removal of current constructors with scalar values.
{ x, y }
would have the signature a -> b -> { x : a, y : b }
So defining the old version constructor would be as easy as:
type alias Point = { x : Int, y : Int }
unsafePoint : Int -> Int -> Point
unsafePoint = { x, y }
Nested fields would not be supported, like the update syntax currently.
This would be useful for example to decode anonymous records that don’t have a type alias:
map2 { x, y }
(field "x" int)
(field "y" int)
Compiler optimization
Record singletons would be used quite a lot, so it could make sense to optimize them even more.
I wonder if optimizing { x = value}
to value
when --optimize
is used would be possible, but maybe ports automatic conversions would prevent this (if this was the only issue, forbidding single-field records in ports could be reasonable).
Feedback
I may have completely neglected some valid use cases of current records constructors signature, but the exercise was interesting nonetheless.
- What do you think? Do you see any drawback?
- Has this been discussed before?
Edit: the anonymous record constructor syntax has been discussed by the past in Easily Constructing Records · Issue #73 · elm/compiler · GitHub, with the following comment:
{x,y}
is exactly the same as the pattern matching syntax and it is not clear that it is a function.
Another proposal was:
There’s also
{x=,y=}
to consider - similar to(,,,)
in that only the actual values are left out. Also reminiscent of the way binary operators are curried in Haskell ((5+)
or(+x)
).