I want to specify a type for records that have an id
field whose type is a Uuid
. So, I'm using extensible records. The type looks like this:
type alias WithId a = { a | id : Uuid }
So far, so good. But then I went to create a Json.Encoder
-- that is, a function of type WithId a -> Value
-- for this type. I need a way to encode the underlying value, so I want to take a first argument of type a -> Value
. Since I know a
is a record, I can safely assume it is encoded to a JSON object, even though Elm doesn't expose the data types for Value
.
However, when I create such a function, I get a compile error:
The argument to function `encoder` is causing a mismatch.
27| encoder a
^
Function `encoder` is expecting the argument to be:
a
But it is:
WithId a
Hint: Your type annotation uses type variable `a` which means any type of value
can flow through. Your code is saying it CANNOT be anything though! Maybe change
your type annotation to be more specific? Maybe the code has a problem? More at:
<https://github.com/elm-lang/elm-compiler/blob/0.18.0/hints/type-annotations.md>
Detected errors in 1 module.
I'm confused. Isn't something of type WithId a
going to contain all the fields of a
, and therefore shouldn't WithId a
be an a
through the type alias
structural typing?
What am I missing? Why isn't the type alias of WithId a
allowing me to use a WithId a
as an instance of a
, even though it is defined as {a|...}
?
ADDENDUM
I've marked an answer below (which amounts to "You just can't do that, but here's what you're supposed to be doing."), but I'm still a bit unsatisfied. I guess I'm confused by the use of the term type alias
. My understanding was that records were always a type alias
, because they were explicitly specifying a subset of fields among all possible fields...but the underlying type was still the unifying record type (akin to a JS Object).
I guess I don't understand why we say:
type alias Foo = { bar : Int }
instead of
type Foo = { bar : Int }
The former implies to me that any record with { bar : Int }
is a Foo
, which would imply to me that {a|bar:Int}
is both of the same type as a
and a Foo
. Where am I wrong here? I'm confused. I feel like I'm not grokking records.
ADDENDUM 2
My goal is not just to have a type WithId
that specifies there is an .id
on a field, but rather to have two types: Foo
and WithId Foo
where Foo
has structure { bar:Int }
and WithId Foo
has structure { bar:Int, id:Uuid}
. I'd then like to also have a WithId Baz
, WithId Quux
, etc., etc., with a single encoder and single decoder function for WithId a
.
The basic issue is that I have persisted and non-persisted data, which have the exact same structure, except that once something is persisted, I have an id. And want I type-level guarantees that records are persisted in certain cases, so Maybe
doesn't work.
Your reasoning about what you know about a WithId a
value and what you should therefore be able to do with it (i.e. treat it as an a
) is logically sound, but Elm’s support for extensible records just doesn't play with the type system in a way that permits that. Indeed, Elm’s extensible record types are specifically designed not to allow access to the full record.
What extensible records are designed for is to declare that a function will only access certain fields of a record, and the compiler then enforces that constraint:
type alias WithId a =
{ a | id : Uuid }
isValidId : WithId a -> Bool
isValidId withId =
-- can only access .id
updateId : WithId a -> WithId a
updateId withId =
{ withId |
id = -- can only access .id
}
When used to write functions that work on a small slice of a large program model, for example, this provides powerful guarantees about what a given function is able to do. Your updateUser
function, for example, can be constrained only to access the user
field of your model, rather than having free reign to access perhaps dozens of fields in your model. When you are later trying to debug an unexpected change to your model, these constraints help you quickly rule out many functions as being responsible for that change.
For more concrete examples of the above, I recommend Richard Feldman’s elm-europe 2017 talk https://youtu.be/DoA4Txr4GUs (see 23:10 in that video for the part showing extensible records).
I had the same excitement followed by confused disappointment when I first learned about extensible records, but in hindsight I think that comes from my deeply ingrained Object Oriented Programming instincts. I saw “extensible” and my brain jumped straight to “inheritance”. But Elm doesn't implement extensible records in a way that can support type inheritance.
The real intended purpose of extensible records, as illustrated in the examples above, is to constrain functions’ access to only a subset of a large record type.
As for your specific use case, I'd suggest starting with an Encoder
for each of your data types, and if you want to reuse the implementation of an ID encoder, call a shared idEncoder
function from within each of your encoders.