Holmusk / elm-street

:deciduous_tree: Crossing the road between Haskell and Elm

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

[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:

screenshot 2019-02-22 at 12 23 31 pm

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

  1. 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.
  2. 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!