[RFC] Find best solution for dealing with (phantom) type variables
chshersh opened this issue · comments
It's highly desired to have datatypes with phantom type variables. Like this one:
newtype Id a = Id { unId :: Text }
But there are problems with using such data types in Elm. For example, if we have the following Haskell data type:
data User = User
{ userId :: Id User
, userName :: Text
}
then elm-street
generates the following Elm definition:
type Id a
= Id String
type alias User =
{ id : Id User
, name : String
}
Unfortunately, this is invalid Elm (version 0.19) and produces the following error:
This type alias is recursive, forming an infinite type!
51| type alias User =
^^^^
When I expand a recursive type alias, it just keeps getting bigger and bigger.
So dealiasing results in an infinitely large type! Try this instead:
type User =
User
{ id : Id User, name : String }
Hint: This is kind of a subtle distinction. I suggested the naive fix, but I
recommend reading <https://elm-lang.org/0.19.0/recursive-alias> for ideas on how
to do better.
This is not restricted only to phantom type variables, we can't have type aliases that reference themselves. So we need to do better. I would like to discuss possible solutions to this problem and choose the most ergonomic one.
Solution 1: Drop phantom type variables on frontend and tell custom compiler error [current solution]
Instead of preserving phantom type variables on the frontend, we just drop them:
type alias Id =
{ id : String
}
type alias User =
{ id : Id
, name : String
}
If you derive Elm
typeclass for data types with type variables, the following compiler error is shown:
Solution 2: Generate fake data type for using in phantom variables
Type aliases can refer to themselves. But they can refer to some other types.
type Id a
= Id String
type User_ = User_
type alias User =
{ id : Id User_
, name : String
}
Solution 3: Always create type
instead of type alias
Just don't generate type aliases at all. Always create type
:
type Id a
= Id String
type User = User
{ id : Id User
, name : String
}
This might be less convenient to use.
Solution 4: Generate type
instead of type alias
only when the type alias reference itself
This is hard to implement, because we need to implement algorithms for finding cycles in data type definitions.
I would like to propose another solution. We can take advantage of extensible records to implement this.
See example bellow:
type Id a = Id String
type alias WithId r = { r | id : Id r }
type alias User = WithId
{ name : String
, age : Int
}
user : User
user =
{ id = Id "1"
, name = "Jane Doe"
, age = 54
}
in repl:
> user
{ age = 54, id = Id "1", name = "Jane Doe" }
: User
How does it work?
With usage of extensible record, we exclude the id
filed from the record which is used as a parameter of Id
type. Since there is no Id a
in a
, there is no recursion in type alias.
This illustrates what is going on (repl):
> user.id
Id "1" : Id { age : Int, name : String }
Drawback
- By definition 2 aliases with the same structure are just synonyms for the same thing. If 2 records has same fields, their IDs will also be of same type.
- possibly hard to implement
I just come up with another trick using extensible records that might be even more useful:
type Id a
= Id String
type OwnId
= OwnId String
type alias User =
{ id : OwnId
, name : String
, age : Int
}
getId : { a | id : OwnId } -> Id { a | id : OwnId }
getId rec =
case rec.id of
OwnId str ->
Id str
user : User
user =
{ id = OwnId "1"
, name = "Jane Doe"
, age = 54
}
userId : Id User
userId =
getId user
loaded to repl:
> user.id
OwnId "1" : OwnId
> userId
Id "1" : Id User
All we need to do is to generate OwnId
by elm street. Then in elm we can use getId
to get type safe Id User
type to work with.
Update
I also managed to break this:
type Own a
= Own a
type Tag a t
= Tag a
type alias OwnId =
Own String
type alias Id t =
Tag String t
getOwn : (a -> Own b) -> a -> Tag b a
getOwn get val =
case get val of
Own a ->
Tag a
getId : { a | id : OwnId } -> Id { a | id : OwnId }
getId =
getOwn .id
type alias Post =
{ id : OwnId
, text : String
, userId : Id User
}
type alias User =
{ id : OwnId
, name : String
, age : Int
, posts : List (Id Post)
}
post : Post
post =
{ id = Own "1"
, text = "FooBar"
, userId = getId user
}
user : User
user =
{ id = Own "1"
, name = "Jane Doe"
, age = 54
, posts = [ getId post ]
}
will again result in recursion:
This type alias is part of a mutually recursive set of type aliases.
39| type alias User =
^^^^
It is part of this cycle of type aliases:
┌─────┐
│ User
│ ↓
│ Post
└─────┘
You need to convert at least one of these type aliases into a `type`.
Note: Read <https://elm-lang.org/0.19.1/recursive-alias> to learn why this
`type` vs `type alias` distinction matters. It is subtle but important!