DEV Community

John Pavlick
John Pavlick

Posted on

Record Type Alias Combinators: A Public Service Announcement

I've been trying and failing to write this essay for well over a week, so I'm going to start by unburying the lede and we'll take it from there:

I've figured out how to leverage Elm's type system in such a way so as to allow for the creation of record type aliases that create a constraint against the constructors that a given record type alias must expose. Sort of like... record type alias combinators.

These record type aliases can be composed to expose a set of chained, nested "builders" for a type; and they can be implemented for any type.

I'm doing my best here; perhaps a smarter and kinder soul than I, can explain my own discovery to me a little better, and if any of you can do that, please do - but here's what I'm getting at - you can generalize and combine properties on record type aliases.


Imagine that you're building a new design system. From scratch. You just finished reading Refactoring UI1, and you're 100% ready to "limit your choices" and "define systems in advance" and "pick five colors that you like but don't call them by their names, call them some semantic thing like Primary or Danger or whatever".

Color's easy. We'll start there. You know the drill: from the void, conjure a type; give it named constructors; map them to a value:

type Color
    = Primary
    | Secondary
    | Success
    | Danger
    | Warning
    | Info
    | Light
    | Dark


colorToAttr : Color -> Html.Attribute msg
colorToAttr color =
    Attr.style "background-color" <|
        case color of
            Primary ->
                "#0d6efd"

            Secondary ->
                "#6c757d"

            Success ->
                "#198754"

            Danger ->
                "#dc3545"

            Warning ->
                "#ffc107"

            Info ->
                "#0dcaf0"

            Light ->
                "#f8f9fa"

            Dark ->
                "#212529"
Enter fullscreen mode Exit fullscreen mode

Hey, alright - now we can just use our Color type to create attrs. Easy.

Wait, hold on - incoming Slack message - Product wants to know:

You're building this design system around light mode and dark mode, right?

Okay okay, nothing we can't handle:

type DisplayMode
    = LightMode
    | DarkMode


modedColorToAttr : DisplayMode -> Color -> Html.Attribute msg
modedColorToAttr displayMode color =
    case displayMode of
        LightMode ->
            colorToAttr color

        DarkMode ->
            Attr.style "background-color" <|
                case color of
                    Primary ->
                        "#0d6efd"

                    Secondary ->
                        "#6c757d"

                    Success ->
                        "#198754"

                    Danger ->
                        "#dc3545"

                    Warning ->
                        "#ffc107"

                    Info ->
                        "#0dcaf0"

                    Light ->
                        "#f8f9fa"

                    Dark ->
                        "#212529"
Enter fullscreen mode Exit fullscreen mode

There we go. These colors will work for....... buttons.

Definitely not for backgrounds. Or for lighter variants. Sigh. Time to start building up a bunch of types. With parameters. Zipping around everywhere.

Drowning in a pile of types, again.

I mean, I guess we could just do it in CSS... right? CSS sucks, but it's a known quantity of suck, and the designers know it, and... I guess we'll never have a fully-specified design system.


But what if there was another way? What if there was a way to create tighter constraints, that didn't require developers using a design system to pass around types?

What if there was a way, as the gardener-in-chief of a design system, to create generic kinds of specifications, and compose them together?

What do I always say, on this website?

We're better. And we can do better. LFG.


Designing a System to Design Design Systems

Here's the trick - we have to start thinking in terms of creating generic specifications for these design system elements.

For instance - what if we could generalize over the idea of a color palette - over the choices between Primary, Secondary, Danger, etc - and then generalize over the choices between Background and Font and Border - and compose them together, and still have some type safety, and implement different things differently at each call site, while still leaning on the compiler?

You people have got to check this out. Let's build a type. You wanna build a type? Let's build a type, let's do it.2

type Hue
    = Primary
    | Secondary
    | Danger
Enter fullscreen mode Exit fullscreen mode

So far, nothing out of the ordinary, right? I mean, let's call it Hue instead of Color - Color is a bigger idea - let's save it for something bigger.

type Target
    = Background
    | Font


type Opacity
    = Op100
    | Op70
    | Op30


type Accent
    = Normal
    | Subtle


type Color
    = Color Target Opacity Accent Hue
Enter fullscreen mode Exit fullscreen mode

"John, it seems like we're drowning under a pile of types again--"

Let me cook. I know you know how to make a type. This is all setup, and if you want to enable this kind of flexibility in your design system - you're going to have to specify it. And you're going to specify it eventually, regardless - but if you don't specify it on purpose, you'll specify it on accident. We have a word for that, but I can't remember what it's called.3

type alias AllHues builder =
    { primary : builder
    , secondary : builder
    , danger : builder
    }
Enter fullscreen mode Exit fullscreen mode

A type alias? You're repeating the names of the constructors as properties? And assigning them to a type parameter?

I am. And quickly now, I'm going to do it again for the others:

type alias AllTargets builder =
    { background : builder
    , font : builder
    }


type alias AllOpacities builder =
    { op100 : builder
    , op70 : builder
    , op30 : builder
    }


type alias AllAccents builder =
    { normal : builder
    , subtle : builder
    }
Enter fullscreen mode Exit fullscreen mode

So what did we just do, there?

For every value in the constructor of the Color type, we specified the possible constructors as a record type alias with a type parameter.

And a type parameter can be anything. Even a function. But it truly can be anything - what good is AllHues if the values for primary, secondary, and danger are open?

Let's make them concrete by providing an implementation - a function that, for each record property, supplies a value that matches that property:

allHues : (Hue -> builder) -> AllHues builder
allHues toBuilder =
    { primary = toBuilder Primary
    , secondary = toBuilder Secondary
    , danger = toBuilder Danger
    }


allTargets : (Target -> builder) -> AllTargets builder
allTargets toBuilder =
    { background = toBuilder Background
    , font = toBuilder Font
    }


allOpacities : (Opacity -> builder) -> AllOpacities builder
allOpacities toBuilder =
    { op100 = toBuilder Op100
    , op70 = toBuilder Op70
    , op30 = toBuilder Op30
    }


allAccents : (Accent -> builder) -> AllAccents builder
allAccents toBuilder =
    { normal = toBuilder Normal
    , subtle = toBuilder Subtle
    }
Enter fullscreen mode Exit fullscreen mode

Do you see it, yet?

Try this: what happens if we just call one of these functions?

If we were going to do that, what kind of function would we pass in as the (a -> builder) parameter?

Let's try identity - can't hurt:

allHues_ =
    allHues identity
Enter fullscreen mode Exit fullscreen mode

Let the compiler infer the type.

What type is it?

Oh, it's an AllHues Hue.

What happens if we access a property on that value?

someHue =
    allHues_.primary
Enter fullscreen mode Exit fullscreen mode

It's... a Hue?

So, all of the props on allHues_ are Hues. That's not too surprising - but that was just with passing in identity.

We could pass in... other functions... couldn't we? And then each property on allHues_ would be - a Hue, applied to some function.

Check this out - check out this type:

type alias AllColors builder =
    AllTargets (AllOpacities (AllAccents (AllHues builder)))
Enter fullscreen mode Exit fullscreen mode

It's.......

  • An AllTargets builder, where builder is
    • An AllOpacities builder, where builder is
      • An AllAccents builder, where builder is
        • An AllHues builder.

What if we made the final builder concrete?

type alias ColorBuilder =
    AllColors Color
Enter fullscreen mode Exit fullscreen mode

And provided an implementation?

colorBuilder : ColorBuilder
colorBuilder =
    allTargets
        (\target ->
            allOpacities
                (\opacity ->
                    allAccents
                        (\accent ->
                            allHues
                                (\hue ->
                                    Color target opacity accent hue
                                )
                        )
                )
        )
Enter fullscreen mode Exit fullscreen mode

What... what is this thing?

GIF of  raw `Color` endraw  type construction from record type alias combinators, with autocomplete from the LSP

It's a set of constraints with a concrete implementation that builds up successive, nested applications of values on a function.

Now that you have a single, unambiguous implementation of a Color type that describes all of the colors and their variations - you can build a renderer for that type; and that renderer can be the single source-of-truth for all of the values that it supports:

colorToAttr : Color -> Html.Attribute msg
colorToAttr (Color target opacity accent hue) =
    let
        targetFragment : String
        targetFragment =
            case target of
                Background ->
                    "background-color"

                Font ->
                    "color"

        opacityFragment : String
        opacityFragment =
            case opacity of
                Op100 ->
                    String.fromFloat 1.0

                Op70 ->
                    String.fromFloat 0.7

                Op30 ->
                    String.fromFloat 0.3

        rgbFragment : String
        rgbFragment =
            case ( accent, hue ) of
                ( Normal, Primary ) ->
                    "13, 110, 253"

                ( Normal, Secondary ) ->
                    "108, 117, 125"

                ( Normal, Danger ) ->
                    "220, 53, 69"

                ( Subtle, Primary ) ->
                    "207, 226, 255"

                ( Subtle, Secondary ) ->
                    "226, 227, 229"

                ( Subtle, Danger ) ->
                    "241, 174, 181"
    in
    Attr.style targetFragment <| String.concat [ "rgba(", rgbFragment, ", ", opacityFragment, ")" ]
Enter fullscreen mode Exit fullscreen mode

So that's all well and good and pretty cool - but you may be thinking:

Won't typing out long dotted strings get sort of tiresome? Is there any value to having a call site that looks like Html.div [ colorBuilder.background.op100.normal.secondary ] [...?

We can go deeper. Check this out - let's create a type alias that makes the builder parameter for AllHues concrete to a function of type String -> Html msg, which is incidentally the type signature of Html.text:

type alias TextColorBuilder msg =
    AllHues (String -> Html msg)


textColorBuilder : TextColorBuilder msg
textColorBuilder =
    allHues
        (\hue string ->
            Html.span
                [ Color Font Op100 Normal hue
                    |> colorToAttr
                ]
                [ Html.text string ]
        )


type alias AllTexts msg =
    { unstyled : String -> Html msg
    , withColor : TextColorBuilder msg
    }


text : AllTexts msg
text =
    { unstyled = Html.text
    , withColor = textColorBuilder
    }
Enter fullscreen mode Exit fullscreen mode

So now we can:

dangerousHello : Html msg
dangerousHello =
    text.withColor.danger "Hello, dangerously!"
Enter fullscreen mode Exit fullscreen mode

Well would you look at that!

Sample of text rendered with our custom  raw `text` endraw  element

Guess what else - we can use the builder type parameter to represent a "builder-pattern" function, so that we can use the output of one of our All*s functions to apply a value to a wrapped configuration type:

type Button msg
    = Button { color : Color, onClick : Maybe msg, label : String }


type alias ButtonBuilder msg =
    Button msg -> Button msg


new : { onClick : Maybe msg, label : String } -> Button msg
new { onClick, label } =
    Button { color = colorBuilder.background.op100.normal.primary, onClick = onClick, label = label }


with :
    { opacity : AllOpacities (ButtonBuilder msg)
    , secondary : ButtonBuilder msg
    , danger : ButtonBuilder msg
    }
with =
    { opacity =
        allOpacities
            (\opacity (Button config) ->
                Button
                    { config
                        | color =
                            (\(Color target _ accent hue) ->
                                Color target opacity accent hue
                            )
                                config.color
                    }
            )
    , secondary =
        \(Button config) ->
            Button
                { config
                    | color =
                        (\(Color target opacity accent _) ->
                            Color target opacity accent Secondary
                        )
                            config.color
                }
    , danger =
        \(Button config) ->
            Button
                { config
                    | color =
                        (\(Color target opacity accent _) ->
                            Color target opacity accent Danger
                        )
                            config.color
                }
    }


view : Button msg -> Html msg
view (Button { color, onClick, label }) =
    Html.button
        [ Maybe.map Html.Events.onClick onClick
            |> Maybe.withDefault (Attr.class "")
        , colorToAttr color
        ]
        [ Html.text label ]
Enter fullscreen mode Exit fullscreen mode

You see that? Did you see that?

  • We decided that one of our constraints for our Button element in our design system was that we could adjust the opacity - so we exposed that via the AllOpacities builder record type alias constraint, and made its builder type parameter a ButtonBuilder msg -
  • Which gives us the flexibility to expose the record-alias dot-accessor "picker" for our opacity value - and then apply that value to a function that updates our Button msg value!

Check out a call site:

sortaTranslucentDangerButton : Html msg
sortaTranslucentDangerButton =
    new { onClick = Nothing, label = "If you push this button, nothing will happen" }
        |> with.opacity.op30
        |> with.danger
        |> view
Enter fullscreen mode Exit fullscreen mode

Image description

There's still so, so much more that can be done with this paradigm - but let's talk about some things that I've found already:

  • You can specify dot-accessor "builders" for constructors on types, for any type - and then compose them together
  • If you're using the LSP, you get on-dot autocomplete - which makes for a great developer experience with nice call sites
  • By leveraging record type aliases as a sort of "namespacing" - you can put more types and values into the same module without having to smurf4, or cut a new module to avoid smurfing
  • You can defunctionalize core parts of an application - for instance, the design system - and separate the call site composition from the implementation - which allows you to colocate related behaviors
  • You can expose All*s builder record type aliases and all*s builder functions from a module while keeping all constructors totally opaque - which gives you a finer degree of control over module opacity, especially since...
  • Properties in a record type alias don't have to be 1:1 with constructors for a type that it's providing an implementation for

If anybody wants to play with this - there's an Ellie at this footnote5.

In closing: I'm over the moon about having figured out how to do this. I'm still struggling to explain it in the abstract, but I think that the biggest key to the question of "what exactly is this Thing" is that its implementation is isomorphic to the design of elm/url6, which the main guy Wolfgang7 pointed out while I was trying to explain it to him the other day.

Un/fortunately, I think that I'm going to have more to say on this as I play with it more and get deeper into it. I'm not sure if anybody else has "discovered" this technique yet, but please @ me on X8 or in Elm Slack if you'd like to discuss this further or if you have any additional insight.


  1. https://www.refactoringui.com/ ↩

  2. NB: I'm going to shorten up these types so that my writing doesn't get buried in a fully-fledged design system - which don't get me wrong, I'd love to create - but I need to get this essay done first. I've been putting it off for nearly two weeks now, and people are starting to Ask Questions. ↩

  3. I just remembered: it's called CSS. ↩

  4. "Smurfing" is jargon for the practice of adding information to a name in order to disambiguate it from other "things" in a given namespace that have similar semantics but a different implementation. For instance - imagine that all of the code from Html.Events was in the same module as Html.Attributes - how would you know which was an event, and which was an attribute? You could prefix all of the event attributes with event - i.e., eventOnClick, eventOnInput, etc - but that would be noisy and repetitive, so it's best avoided when possible. ("Smurfing" is so-called after the popular cartoon "The Smurfs", wherein most characters have the word Smurf in their names - i.e., "Papa Smurf", "Lazy Smurf", "Brainy Smurf", "AccursedUnutterablePerformIO Smurf".) ↩

  5. https://ellie-app.com/pMGzWB8GLjca1 ↩

  6. https://foldp.com/blog/elm-url.html ↩

  7. https://twitter.com/wolfadex ↩

  8. https://twitter.com/lambdapriest ↩

Top comments (4)

Collapse
 
gabrielfallen profile image
Alexander Chichigin

Pretty nice! 😃

From a cursory look, it seems you've found a sort of Type Families in Elm and implemented a kind of Scott encoding or a Final Tagless encoding on top of it? Or am I wrong here? (Not an Elm user, and didn't dig into the weeds, sorry...)

Collapse
 
jmpavlick profile image
John Pavlick

Honestly? I have absolutely no idea what most of those words mean. I'm in way over my head and punching way above my weight here; the most complex HM type system that I'm comfortable in is Elm's. I have much to learn.

Thank you for giving me some new terms to learn about!

Collapse
 
frectonz profile image
Fraol Lemecha

this is awesome

Collapse
 
jmpavlick profile image
John Pavlick

Thank you!!