Extending an existing record

If I have this type alias:

type alias Point2 a =
    { a
    | x : Int
    , y : Int
    }

How do I take a record of type Point2 {} and add a z value to turn into a Point2 { z : Int } record? I tried making a function like this:

makePoint3 : Int -> Point2 {} -> Point2 { z : Int }
makePoint3 z point2 =
    { point2 | z = z }

But the compiler says I can’t add a z field to a Point2 {} record. I know I can do this instead:

{ x = point2.x
, y = point2.y
, z = z
}

But I don’t want to have to do that because it’s very repetitive.

You will need to write out all the fields, the record update syntax no longer allows you to change the type of the record you’re updating by adding additional fields or changing the type of fields.

This was something that existed in an older version of Elm, but it caused a lot of confusion, made compiler error messages harder to do well and was a very rarely used feature of records.

People from an OOP background often see extensible records as a way to start modeling the inheritance style they’re used to but they’re really not designed for that.

Richard has a really nice talk about using extensible record types that will help you get a better idea about what they’re designed to provide.

2 Likes

But the compiler says I can’t add a z field to a Point2 {} record.

The compiler is right. point2 is a Point2 {}. By the definition of Point2 a you gave, this is a record of the shape

{ x : Int,
, y : Int
}

Now you’re trying to update it with { point2 | z = z }. But there is no z field! You can only update fields that already exists.

What happens in the verbose solution that you also gave is that you create a new record that has all fields, x, y, and z. Now since you defined Point2 a as an extended record, the output of makePoint3 will also be a Point2 a, because it has x and y fields (and the additional z).

Copying each field might feel repetitive, but there is an alternative, with some downsides to ergonomics:

type alias Point3 =
    { dd : Point2 {}, -- read dd as 2D :)
    , z : Int
    }

Now you’re not copying fields, but keeping the Point2 a intact and composing it into a new record with additional information. Unfortunately, such a Point3 a is not a Point2 a, because it does not have the x and y fields (they are inside its dd field). So for any function that needs a Point2 a, you need to extract the dd field and pass only that.

This approach is actually slightly nicer. Sure, you have to explicitly convert between 3D and 2D points, but it’s only a quick record field call and you see all conversion. I expect that there are a few subtle bugs that you will spare yourself if you have this explicit conversion. For this to work really well, I’d even suggest not making Point2 a an extensible record. Just have a type alias Point2 = { x : Int, y : Int }.

Extensible records are nice when you only care about a small set of fields of a record. The only good case I can imagine is a Model record, since those necessarily will have a lot of fields. I think it is always better if you can operate on only one field at a time, but sometimes you will need to know about multiple things at once. And for those cases it can be nice to use extensible records to make working with a large struct easier. But I’d argue that it is preferrable to organize your record in a way that you do not need extensible records.

What was your reason to make Point2 a an extensible record in the first place?

The extensible Point2 record I showed is only a simple example of what I am trying to do. I have to parse an XML file that has a deeply nested structure of <Value Name=".." Type=".." Value=".." /> tags and <Values Name="..">, which can contain other Value and Values tags. You can see an example in this unit test. The way I am doing it is with a pipeline where the XML data is gradually processed over time see this file. I have to do it like this because almost every step in the pipeline can fail. I decided to manually type out all the fields like @jessta said.

parseFile : String -> Result String ProjectFile
parseFile xmlString =
    xmlString
        |> XmlParser.parse
        |> Result.mapError deadEndsToString
        |> Result.map .root
        -- Now we have the root node of the XML file. The next 2
        -- functions attempt to get the attributes of the root element.
        |> Result.andThen (getAttr0 "Guid")
        -- Now we have { root, attr0 }
        |> Result.andThen (getAttr1 "Version")
        -- An attr1 field has been added
        |> Result.andThen getSubsystems
        |> Result.andThen getEntities
        -- Now we have { node, attr0, attr1, subsystems, entities }
        |> Result.mapError List.singleton
        |> Result.andThen decodeEntities
        |> Result.andThen decodeSubsystems
        |> Result.map (\a ->
            ProjectFile a.subsystems a.entities a.attr1 a.attr0)
        |> Result.mapError combineErrorStrings

Maybe instead of keeping on changing the type of data I am dealing with, I could just have one type of record where each field is a Maybe that is initially set to Nothing until its value is known.

Have you thought about using Result.map4 or Result.Extra.andMap instead? The basic idea is that you can construct a partial result by partially applying the constructor, so Result.map ProjectFile getSubSystems would conditionally give you a partial ProjectFile, where you either filled in the subsystems, or you got an Err instead.

From a very quick look at the links you posted, I can’t find a place where you needed to cross-refernce something (getEntities doesn’t need to know about .subsystems), which would complicate things are little bit.

If you want to collect all the errors instead of just returning the first one, it might also be an idea to use a different Result a type, like the one from the-sett/elm-error-handling.

1 Like

Thank you so much. I will try that. At first I could not figure out how those functions could possible be used to fix this problem, but now it makes sense. This will make my code so much cleaner.

I refactored the code and I did what you suggested. The diff is here: https://github.com/dullbananas/editsc/commit/2613c0277cb2258b0ac6f295cc0615ffcb72f68b