Compiler trips up on extensible records?

TL;DR

The following type of construction yields compiler errors:

type alias WithId a 
    = { a | id : String }

type alias NameAndId =
    { id : String, name: String }

type Msg a
    = WrapWithId (WithId a)

In the REPL x and y defined below compare true

> x : WithId NameAndId
| x = { id = "id", name = "name" }
|
{ id = "id", name = "name" }
    : WithId NameAndId
> y : NameAndId
| y = { id = "id", name = "name" }
|
{ id = "id", name = "name" } : NameAndId
> x == y
True : Bool

but comparing u and v yields a compiler error (TYPE MISMATCH)

> u = WrapWithId x
WrapWithId { id = "id", name = "name" }
    : Msg NameAndId
> v = WrapWithId y
WrapWithId { id = "id", name = "name" }
    : Msg { name : String }

Original message

Hi,

I am fairly new to Elm, and I have encountered a compiler error, which seems wrong to me. Can anyone help? Below is code to reproduce the error. I have 3 files: Main.elm, Other.elm, and Third.elm.

The compiler does not seem to like line 37: t1 = Third.Third1 o1. It complains:

Detected problems in 1 module.
-- TYPE MISMATCH -------------------------------------------------- src\Main.elm

The 1st argument to `Third1` is not what I expect:

37|     t1 = Third.Third1 o1
                          ^^
This `o1` value is a:

    Other.MyOtherMsg { name : String }

But `Third1` needs the 1st argument to be:

    Other.MyOtherMsg Third.MyThird

Hint: Looks like the id field is missing.

If I comment out line 37 and run the code, I can see the value of o1. It is Other1 { id = "myId", name = "name" }. So at runtime id is present, but at compile time it is not?

The Main.elm file is just the Buttons example given in the Elm guide with line 34-38 added.

Main.elm

module Main exposing (..)

import Browser
import Html exposing (Html, button, div, text)
import Html.Events exposing (onClick)
import Other
import Third
import Debug


-- MAIN


main =
  Browser.sandbox { init = init, update = update, view = view }



-- MODEL

type alias Model = Int

init : Model
init =
  0


-- UPDATE

type Msg = Increment | Decrement

update : Msg -> Model -> Model
update msg model =
  let
    mm = Third.newMyThird "myId"
    o1 = Debug.log "o1" <| Other.Other1 mm
    t1 = Third.Third1 o1
  in
    case msg of
      Increment ->
        model + 1

      Decrement ->
        model - 1


-- VIEW

view : Model -> Html Msg
view model =
  div []
    [ button [ onClick Decrement ] [ text "-" ]
    , div [] [ text (String.fromInt model) ]
    , button [ onClick Increment ] [ text "+" ]
    ]

Other.elm

module Other exposing (..)


type alias MyOther a =
  { a | id : String }


type MyOtherMsg a =
  Other1 (MyOther a)

Third.elm

module Third exposing (..)

import Other


type alias MyThird =
  Other.MyOther { name : String }


newMyThird : String -> MyThird
newMyThird id =
  { id = id, name = "name" }


type MyThirdMsg =
  Third1 (Other.MyOtherMsg MyThird)

Hey! Welcome to Elm! :tada:

Extensible records can be tricky to get right. With these definitions:

type alias MyOther a =
    { a | id : String }

type alias MyThird =
    Other.MyOther { name : String }

You are saying "MyOther is a record with an id field. I do not care about the whatever else is in there." Then you are saying MyThird is a MyOther with only name—no id! MyOther does not add id, it only matches on records that have it already. With that knowledge, is the compiler error clearer?

Mechanically speaking, you could change the definition of MyThird to be Other.MyOther { id : String, name : String }. That would probably compile (although I have not tried it.)

But in the bigger picture, what are you trying to do here? It appears you may want some kind of inheritance? Extensible records would not give you that. If you can say more about what you’re trying to do, I (or someone else) may be able to suggest something that will work well for your situation. :slight_smile:

Thank you!

I just tried to change the definition of the type MyThird to:

type alias MyThird =
  { id : String, name : String }

It still yields the same error. My understanding was, that I was extending the record type definition to include additional fields - see here.

My use case is a bit hard to explain. I am trying to build a reusable, interactive table module (Other.elm here), which are used by several other components (Third.elm) in my main application (Main.elm). I am trying to impose only the restriction that the table rows (MyOther) have id's in the table module.

I’ve been experimenting a little more with this:
MyThird seems to represent a record of type { id : String, name : String }. If I create record literals that’s the only thing accepted:

{-| works
-}
def1 : MyThird
def1 =
    { id = "", name = "" }


{-| fails
-}
def2 : MyThird
def2 =
    { name = "" }


{-| also fails
-}
def3 : MyThird
def3 =
    { id = "", name = "", more = "" }


{-| also fails
-}
def4 : MyThird
def4 =
    { id = "" }

So, to me, the compiler error does seem confusing.

I have reduced this example a little further:

module Main exposing (..)

import Html exposing (text)


type alias NameAndId =
    WithId { name : String }


type alias WithId a =
    { a | id : String }


type WrapWithId a
    = WrapWithId (WithId a)


main =
    let
        mm : NameAndId
        mm =
            { id = "", name = "" }

        t1 : WrapWithId NameAndId
        t1 =
            WrapWithId mm
    in
    text ""

I have renamed some things:

  • MyOther -> WithId
  • MyOtherMsg -> WrapWithId
  • MyThird -> IdAndName
  • Other1 -> WrapWithId

Notice, that you’re wrapping MyOther twice, kind of. Would using MyOtherMsg { name : String } instead of MyOtherMsg { id : String, name : String } work for your usecase?

In general I’d say: Stay away from extensible records as often as you can. You can also just enforce that your table rows have ids by wrapping the records, instead of extending fields:

type alias TableRow content =
    { id : String
    , content : content
    }

Okay! I had thought that 3 files were necessary to replicate the problem. If I modify NameAndId as you suggest - which would work for what I am doing - I still get the error:

module Main exposing (..)

import Html exposing (text)


type alias NameAndId =
    -- WithId { name : String }
    { id : String, name: String }


type alias WithId a =
    { a | id : String }


type WrapWithId a
    = WrapWithId (WithId a)


main =
    let
        mm : NameAndId
        mm =
            { id = "", name = "" }

        t1 : WrapWithId NameAndId
        t1 =
            WrapWithId mm
    in
    text ""

Using a wrapper would be painful in the context of what I am doing, because I frequently need to be able to modify the records.

I ment my suggestion more along the lines of this:

module Main exposing (..)

import Html exposing (text)

type alias IdAndName =
    WithId { name : String }

type alias WithId a =
    { a | id : String }


type WrapWithId a
    = WrapWithId (WithId a)


main =
    let
        mm : IdAndName
        mm =
            { id = "", name = "" }

        -- this signature is the crucial part
        t1 : WrapWithId { name : String }
        t1 =
            WrapWithId mm
    in
    text ""

(Sorry for this NameAndId and IdAndName confusion)

You are right. The code compiles with the modified signature. And the compiler allows me to access the id field on the record inside t1:

main =
    let
        mm : NameAndId
        mm = Debug.log "mm" <| { id = "id", name = "name" }

        -- t1 : WrapWithId NameAndId
        t1 : WrapWithId { name : String }
        t1 = Debug.log "t1" <| WrapWithId mm
    in
    case t1 of
      WrapWithId m2 ->
        let
          m3 = Debug.log "m3" m2
        in
        text m2.id

Surely this must be a an error in the compiler.

Related GitHub issue: https://github.com/elm/compiler/issues/2071

I don’t think the related GitHub issue #2071 is a bug, some comments have been added since to explain why.

Also for your last example, if you remove all the aliases, you get:

module Main exposing (..)    
    
import Html exposing (text)    
    
    
type WrapWithId a    
    = WrapWithId { a | id : String }    
    
    
main =    
    let    
        mm : { id : String, name : String }    
        mm =    
            { id = "", name = "" }    
                                                   
        t1 : WrapWithId { id : String, name : String }    
        t1 =    
            WrapWithId mm    
    in    
    text ""   

The WrapWithId constructor takes a record a with at least an id : String field and returns an expression whose type is WrapWithId a.

You pass it a { a | id : String }, it returns a WrapWithId a.
It does not return an expression with type WrapWithId { a | id : String}.

So when you pass it a { id : String, name : String }, it returns a WrapWithId { name : String }.


You can name the constructor differently to better understand:

type WithId a = RequiresId { a | id : String }

If you call RequiresId with a { id : String }, you get a WithId {}.
Let’s test in the repl:

> type WithId a = RequiresId { a | id : String }

> RequiresId { id = "" }
RequiresId { id = "" } : WithId {}

So if you call it with a { id : String, name : String }, you get a WithId { name : String }:

> RequiresId { id = "", named = "" }
RequiresId { id = "", named = "" }
    : WithId { named : String }

Therefore your type in your example must be:

        t1 : WrapWithId { name : String }    
        t1 =    
            WrapWithId mm    
1 Like

I must disagree. It is a bug. To see why, look at the following in the Elm REPL:

> type alias NameAndId = { id : String, name : String }
> type alias WithId a = { a | id : String }
> x : WithId NameAndId
| x = { id = "id", name = "name" }
|
{ id = "id", name = "name" }
    : WithId NameAndId
> y : NameAndId
| y = { id = "id", name = "name" }
|
{ id = "id", name = "name" } : NameAndId
> x == y
True : Bool

Two records cannot compare true, unless they have the same type, i.e. NameAndId and WithId NameAndId are one and the same type.

If I now define

type Msg a
    = WrapWithId (WithId a)

The REPL accepts the following two definitions:

> u = WrapWithId x
WrapWithId { id = "id", name = "name" }
    : Msg NameAndId
> v = WrapWithId y
WrapWithId { id = "id", name = "name" }
    : Msg { name : String }

but then complains when I compare u and v (mind you x and y compared True):

> u == v
-- TYPE MISMATCH ---------------------------------------------------------- REPL

I need both sides of (==) to be the same type:

23|   u == v
      ^^^^^^
The left side of (==) is:

    Msg NameAndId

But the right side is:

    Msg { name : String }

Different types can never be equal though! Which side is messed up?

Hint: Seems like a record field typo. Maybe id should be name?

Hint: Can more type annotations be added? Type annotations always help me give
more specific messages, and I think they could help a lot in this case!

An simpler definition of the Msg type does not provoke this type of error:

> type Msg2 a = Wrap a
> u2 = Wrap x
Wrap { id = "id", name = "name" }
    : Msg2 (WithId NameAndId)
> v2 = Wrap y
Wrap { id = "id", name = "name" }
    : Msg2 NameAndId
> u2 == v2
True : Bool
1 Like

I see. There is indeed an inconsistency.

Another way to illustrate it is:

type alias Named a = { a | name : String }    
    
x : Named { name : String }    
x = { name = "" }    
    
y : Named {}    
y = { name = "" }    
    
z : { name : String }    
z = { name = "" }  

Then

> x == z
True : Bool
> y == z
True : Bool
> x == y
-- TYPE MISMATCH ---------------------------------------------------------- REPL

I need both sides of (==) to be the same type:

4|   x == y
     ^^^^^^
The left side of (==) is:

    Named { name : String }

But the right side is:

    Named {}

Different types can never be equal though! Which side is messed up?

I like that. Your example is even simpler. Can I use it to raise an issue on Github?

I have already opened a new issue (because this is the preferred way to introduce new a SSCCE):

Thank you for insisting @jako , this has lead to a short and indisputable example of the issue.
I have added a note in the GitHub issue to point to this thread.

2 Likes

Yes, thank you! I feel I was not actually that informative above and I’m glad you persisted here!

2 Likes

In your experience, how long does it usually take before such a bug gets fixed? I am wondering whether to wait for a fix to arrive, attempt to redesign my application, or to give up on implementing my project in Elm.

I would not rely on waiting for a fix, this could take some time, even years, particularly for such an issue that seems to be solvable by signatures changes.

So I struggle to see how this could block you, I thought that the signature change solved your issue (didn’t you set the post as “solved”?). Do you have an example of the code that you cannot change to make it work with a different signature?

Also to go back to the first @brian post, designing types around extensible records in Elm is often a mistake better solved by another design. Extensible records are mainly useful in Elm for narrowing the types of functions, not to model the problem domain. Maybe give a try at another modeling before giving up, I’m sure a lot of folks would help if you give us more information about what problem you are trying to solve.

My application is a data science application that displays several interactive tables in a SPA. Each table has different columns with possibly different data types. An earlier version of the app used dwyl/elm-input-tables, but that package has not been updated for Elm 0.19, and I had to hack it in too many places to suit my needs, so I decided to roll my own table module.

I have separated all the table logic out into a Table.elm containing a model, a view function and an update function. In my main application I configure each table separately, and messages to each table are wrapped, so the main update function looks like

case msg of
  Table1Wrap m ->
    { model | table1 = Table1.update m }
  Table2Wrap m ->
    { model | table2 = Table2.update m }
  ...

The type of the messages to each table depend on the record corresponding to a row in the table. And this is where my problem arise. In my Table.elm a row record is an extensible record

type alias Row a = { a | id : String }

@Philipp_Krueger suggested in one of his posts that I used a row wrapper record type. I initially resisted this, because it would complicate updating the rows (now nested records), as per e.g. this.

I marked @Philipp_Krueger’s post as a solution because his code was able to compile with the signature change, but it does not solve the fundamental issue at stake. It does not suffice for my needs. Maybe I should unmark it as the solution?

1 Like

Ok, could you add the minimum required to make this skeleton based on your description representative of your issue?

https://ellie-app.com/8HRLD8YZvVpa1

I would unmark the solution, because this problem is very persistent.

We use atomic design at work, so we have a lot of records wrapping records honoring the atomic design structure. We have had to hack this many times, and it is impossible to know when we discarded a smart approach to a need because this compiler bug hinted us in the wrong direction. I assumed that this was a commonly known limitation of the elm compiler because the discrepancy becomes obvious if you use intellij-elm: it uses its own parser that does not have the bug and thus errors do not match when this arises.