Polymorphic html tag with builder pattern

Hello everyone! First post here, but I lurked for a long time with another account :smile:

I have a problem with a design system built in Elm. I’m implementing a component with the builder pattern that render into an h2 tag by default. This tag could be changed by the user with an utility

withTag : String -> Config -> Config
withTag : nodeName (Config configuration) =
    Config {configuration | tag = string}

The render part then use it this value from the configuration to build Html.node.

I’ve two problems at hand right now which I was not be able to solve:

  • Is there a way to restrict which Strings can be passed to withTag to a specif subset of allowed tags (e.g. h1, h2, h3, h4 and so on)?
  • This behaviour will be replicated in another component but with a different list of allowed tags

I tried to define another opaque type Tag with variants H1, H2 etc. etc., to solve the first problem and then change withTag to:

withTag : Tag -> Config -> Config
withTag : tag (Config configuration) =
    Config {configuration | tag = tag}

and this works just fine. However I have now a problem with the second point since this union type cannot be restricted or expanded for others component that needs to have a different allowed set of tags.

I tried to look up at phantom types as well with an implementation like

type Tag state = Tag String 

type AllowedTag = AllowedTag 
type DisallowedTag = DisallowedTag 

tag : String -> Tag state 
tag nodeName = 
  if nodeName == "h1" then 
     allowTag nodeName 
  else 
     disallowTag nodeName

allowTag : String -> Tag AllowedTag 
allowTag = 
   Tag 

disallowTag : String -> Tag DisallowedTag 
disallowTag = 
   Tag 

withTag : Tag Allowed -> Config msg -> Config msg 

But sadly the method tag won’t compile since the if branches return different types.

Is there a solution to this problem?

What does the rest of the API look like? My thought would be something like

newH2 "some text"
    |> withX
    |> withY
    |> view

but I’m not sure if that fits with your designs.

It’s more like

title "text"
  |> withTag "h3"
  |> withId "an-id"
  |> view

title by default renders an

h2 [] [text "text"]

But withTag "h3" the rendered Html would be

h3 [] [text "text"]

I might be missing something, but doesn’t your Config type need a type variable to “announce” the type for the tags it allows? So your title builder start would use the type Config TitleTag and your other component would return something like Config SubtitleTag. Your function would then be typed withTag : tag -> Config tag -> Config tag?

Yes, this is a valid alternative. I was trying to came up with a different solution since the two sets TitleTag and SubtitleTag have some shared type (like tag h3 for example) and I wanted to avoid duplication. Also if these two types TitleTag and SubtitleTag are opaques, I need to duplicate constructor methods as well (e.g h2: TitleTag and h2: SubtitleTag).

I know it is formally correct, since the sets of these two types are different (allowed tags for two different modules) even if the underlie object (a “tag”) it’s the same, but it still seems strange to me for some reasons :smiley:

There is a pattern that I’ve used quite a bit that solves this problem. It uses two of the more advanced/confusing features of Elm in combination: extensible records and phantom types.

I would make it look like this:

module TagBuilder exposing (Supported, Config, Tag, header, content, withTag, h1, h2, h3, p, div)

-- This is a phantom type, meaning it has no impact on the runtime
-- only there to disallow invalid programs at compilation time
type Supported 
    = Supported 

-- Notice the type parameter here - it isn't used in any of the child types
type Config supported =
    Config { tag : String }

type Tag provided =
  Tag String

header : Config { h1 : Supported, h2 : Supported, h3 : Supported }
header =
   Config { tag = "h1" }

content : Config { h3 : Supported, p : Supported, div : Supported }
content =
   Config { tag = "p" }

-- Not howe there the types parameters line up
withTag : Tag t -> Config t -> Config t 
withTag (Tag tag) (Config config) =
   Config {config | tag = tag }

-- Now for the type safe tags:

h1 : Tag { a | h1 : Supported }
h1 = 
   Tag "h1"

h2 : Tag { a | h2 : Supported }
h2 = 
   Tag "h2"

h3 : Tag { a | h3 : Supported }
h3 = 
   Tag "h3"

p : Tag { a | p : Supported }
p = 
   Tag "p"

div : Tag { a | div : Supported }
div = 
   Tag "div"

This API is a little verbose to implement, but has the advantage that each method can take an exact set of options and these sets can be mutually overlapping. The builder is even nicer, in that it can inherit the type restriction from the initial method.

The reason this works is that the options express their type as an extensible record - or a record asserting that it has to have at least the provided fields. So Tag { a | h1 : Supported } is asserting that the type variable has to have at least h1 : Supported, which is indeed what our intended header wants.

Cool Aside

The fun thing is that this works also with lists. So you could do:

withTags : List (Tag t) -> Config t -> Config t
withTags let config = 
    Debug.todo "however you actually want to implement this"

myVal =
    header
      |> withTags [ h1, h3]  -- This type checks

notAVal =
    header
      |> withTags [ h1, div ]  -- This doesn't type check


The final thing to consider with these kinds of typings, is that your type assertions are more strict than type inference. Elm has the property (which is why we say types are optional) that you cannot make your types looser than the types that can be inferred from the code, but you do have the ability to make them stricter. So in this case the type annotations are not optional in the sense that without them you would allow invalid programs.

4 Likes

Thank you gampleman! It was exactly the solution I was looking for! That’s amazing!

I tried phantom types and looked at extensible records, but I never thought to use them together :sweat_smile:

1 Like

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