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"
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"
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
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
"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
}
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
}
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
}
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
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
It's... a Hue
?
So, all of the props on allHues_
are Hue
s. 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)))
It's.......
- An
AllTargets builder
, wherebuilder
is- An
AllOpacities
builder, wherebuilder
is- An
AllAccents builder
, wherebuilder
is- An
AllHues builder
.
- An
- An
- An
What if we made the final builder
concrete?
type alias ColorBuilder =
AllColors Color
And provided an implementation?
colorBuilder : ColorBuilder
colorBuilder =
allTargets
(\target ->
allOpacities
(\opacity ->
allAccents
(\accent ->
allHues
(\hue ->
Color target opacity accent hue
)
)
)
)
What... what is this thing?
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, ")" ]
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
}
So now we can:
dangerousHello : Html msg
dangerousHello =
text.withColor.danger "Hello, dangerously!"
Well would you look at that!
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 ]
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 theAllOpacities builder
record type alias constraint, and made itsbuilder
type parameter aButtonBuilder 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
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 andall*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/url
6, 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.
-
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.Ā ā©
-
I just remembered: it's called CSS.Ā ā©
-
"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 asHtml.Attributes
- how would you know which was an event, and which was an attribute? You could prefix all of the event attributes withevent
- 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".)Ā ā©
Top comments (4)
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...)
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!
this is awesome
Thank you!!