Compile-time checks on values in a list passed as an argument

After reading @jfmengels’s post about using phantom types to single out an element of a type, I was curious to see if I could apply this technique to constrain a list of values passed as an argument.

In this post, I explored two different variations (nested type tags and extensible records) and came to a (probably unsurprising) conclusion that extensible records are more flexible.

Extensible records make it possible to get the compiler to enforce that only supported attributes are passed in a list.

But ideally, I’d like to be able to enforce two types of constraints on a list of values given to a function:

  • required values are supplied in the list
  • only supported values are supplied.

This would allow for a nice API in situations where, for example, a function takes a list of attributes or a list of child elements etc.

However, I wasn’t able to find a way of enforcing both of those with phantom types, so my current thinking is that the best solution is to have two arguments: a record with required attributes as the first argument followed by a list of optional attributes which the compiler rejects if they’re not supported by the function according to phantom type tags. This is similar to the elm-ui API in Element.Input, for example (minus the phantom types).

Is it possible to have a better API than this? I’d be keen to hear of ways to enforce more compile-time checks.

5 Likes

If it was possible, it would be a kind of NonEmptyList, wouldn’t it? Which is not possible with a simple List.


If you are ready to give up on the list, a close alternative that allows to enforce both constraints is a function composition API with phantom types like this:

rect ( width 1 >> height 2 )

For example for a type like:

type Shape
    = Rect (List (Attr { width : Set, height : Set }))

You can have attribute constructors like:

width :
    Int
    -> List (Attr { compatible | width : Required })
    -> List (Attr { compatible | width : Set })

height :
    Int
    -> List (Attr { compatible | height : Required })
    -> List (Attr { compatible | height : Set })

then you create a rectangle with a function rect:

rect :
    (List (Attr { width : Required, height : Required })
     -> List (Attr { width : Set, height : Set })
    )
    -> Shape

for example:

rect ( width 1 >> height 2 )

You get compiler errors if:

  • You forget one of width or height
  • You set twice a width or a height
  • You set an unsupported attribute

You can also notice that the API is actually used almost exactly like the list one, except [ and ] is replaced by ( and ), and , by >>.

A drawback though is that if all arguments are optional, you have to pass identity instead of [], which is less intuitive.

You can add attributes supported by any shape, like:

color : String -> List (Attr a) -> List (Attr a)

For example:

rect (width 1 >> height 2 >> color "blue")

Or optional attributes supported by a subset of shapes, for example:

type Shape
    = Rect (List (Attr { width : Set, height : Set, rounded : Supported }))

rounded :
    Int
    -> List (Attr { compatible | rounded : Supported })
    -> List (Attr { compatible | rounded : Supported })

Here is a full example with several shapes to play with:
https://ellie-app.com/8TKSpYH8TNfa1

6 Likes

Here is another example with an API for padding and margin that guarantees that each edge has been set exactly once, whatever the function used:

padding (all 10)
padding (horizontal 5 >> top 10 >> bottom 20)
margin (horizontal 15 >> vertical 25)

Any forgotten or duplicated edge leads to a compiler error:

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

Very cool examples, thank you! I’m quite happy to give up on the list if it leads to a better result. I think this composition approach should be functionally equivalent to the phantom builder pattern but the implementation looks a bit simpler here.

Both your composition approach and the phantom builder pattern have pretty good ergonomics - it’s possible to extract a bunch of attributes into a function for reuse, and combining attributes is actually somewhat easier than with a list. Plus of course there’s a more extensive constraint system here!

It looks like I can also have an attribute which is required by some functions but optionally accepted by others. Here I made rounded required by square and optionally accepted by rect: https://ellie-app.com/8TRXW56gFdKa1

I’m not sure that the Supported tag is required, maybe Set is enough as optional attributes can use it as well?

I think the only potential limitation that I see is that required attributes are overconstrained: it isn’t possible to override required attributes once they are set, whereas I can do that with optional attributes. This might be inconvenient in practice: taking your second example, I might set paddings with all by default, but then want to override just the top padding for some elements. On the other hand, overriding can be confusing as well if it’s done by accident (but then, it would be consistent to disallow it for optional attributes too).

I used another tag to improve the signatures and error messages. I think it’s also somewhat deceiving to use Set on an optional argument, but it could work indeed.

It’s possible. For example change the top signature to:

top : Int -> Edges { edges | top : a } -> Edges { edges | top : Set }
top n (Edges edges) =
    Edges { edges | top = n }

And the top edge will have to be set at least once, but can be overridden:
https://ellie-app.com/8TSkWszCsZga1

padding (horizontal 5 >> top 10 >> bottom 20 >> top 9)

On the other hand, preventing optional arguments to be overridden may be trickier.


As an aside you should also take into account the types legibility and the compiler error messages when comparing solutions. For example using simple types like elm-ui for padding, paddingXY and paddingEach has the advantage to provide very simple signatures and error messages, even if it’s slightly less expressive. If I remember correctly, @mdgriffith even avoids record aliases in some functions types to improve error messages.

2 Likes

This is awesome!

You’re right about considerations for types. Things can get rather unwieldy if there is a large number of attributes, for example. There are also implications for testing and package publishing (due to type signature changes).

Where there are lots of attributes, extensible record aliases can help for phantom records. For example see the following PR that removes around 3500 lines of types:

Compiler errors are slightly impacted though, as noted there.

Yep, I tried aliases too but wasn’t sure about the legibility of errors afterwards.

For completeness, note that you can also use the famous OneMoreThan type to have attributes that you have to use a fixed number of times.

For example the ubiquitous “Good/Fast/Cheap Pick two”, guaranteed by types:
https://ellie-app.com/8V2wj6DBpnva1

module Main exposing (main)

import Html exposing (Html)


main : Html msg
main =
    Html.text <|
        Debug.toString <|
            pick (good >> fast)


type Unset
    = Unset Never


type Set
    = Set Never


type OneMoreThan a
    = OneMoreThan (OneMoreThan a)


type alias TwoMoreThan a =
    OneMoreThan (OneMoreThan a)


type Pick a
    = Pick { fast : Bool, good : Bool, cheap : Bool }


pick :
    (Pick { unset | picked : Unset } -> Pick { set | picked : TwoMoreThan Unset })
    -> Pick { set | picked : TwoMoreThan Unset }
pick toPick =
    toPick (Pick { fast = False, good = False, cheap = False })


fast : Pick { any | picked : a, fast : Unset } -> Pick { any | picked : OneMoreThan a, fast : Set }
fast (Pick soFar) =
    Pick { soFar | fast = True }


good : Pick { any | picked : a, good : Unset } -> Pick { any | picked : OneMoreThan a, good : Set }
good (Pick soFar) =
    Pick { soFar | good = True }


cheap : Pick { any | picked : a, cheap : Unset } -> Pick { any | picked : OneMoreThan a, cheap : Set }
cheap (Pick soFar) =
    Pick { soFar | cheap = True }
3 Likes

Yup! I do avoid record aliases in those cases specifically for the error messages.

Basically to address this issue: https://github.com/elm/error-message-catalog/issues/331

2 Likes

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.