Skip to content

A library that allows you to "tag" a value with a specific type for compile time verification.

License

Notifications You must be signed in to change notification settings

joneshf/elm-tagged

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

46 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

elm-tagged

Build Status

A library that allows you to "tag" a value with a specific type for compile time verification.

The semantics associated with the value do not change. If you had an Int, Maybe Bool, or List String before, it's still an Int, Maybe Bool, or List String respectively. It just now has a type level tag.

Tagging a value is useful for making compile time assertions about what you're building. For instance, let's say you are modeling two resources from a server User and Comment. Both of these resources have unique identifiers which could be represented as Ints.

We might have the following:

type alias User =
  { ident : Int
  , ...
  }

type alias Comment =
  { ident : Int
  , ...
  }

Although the two are very similar, a User identifier should not be used in the same way as a Comment identifier.

Since these two records use the same underlying type for their ident fields, the type checker wont stop you from using a User identifier where a Comment identifier was expected.

A first pass at making this distinction might be to give each identifier a type alias.

type alias UserIdent =
  Int

type alias User =
  { ident : UserIdent
  , ...
  }

type alias CommentIdent =
  Int

type alias Comment =
  { ident : CommentIdent
  , ...
  }

Now you as a human can tell a little better that the two identifiers are different. However, the type checker still wont stop you from using a User identifier where a Comment identifier was expected.

A second pass at making this distinction might be to give each identifier its own type.

type UserIdent
  = UserIdent Int

type alias User =
  { ident : UserIdent
  , ...
  }

type CommentIdent
  = CommentIdent Int

type alias Comment =
  { ident : CommentIdent
  , ...
  }

Now you as a human can tell a little better that the two identifiers are different. And, the type checker WILL stop you from using a User identifier where a Comment identifier was expected!

However, note that now operations like (<) don't work with UserIdent and CommentIdent.

So you might write a function for that.

type UserIdent
  = UserIdent Int

type alias User =
  { ident : UserIdent
  , ...
  }

ltUserIdent : UserIdent -> UserIdent -> Bool
ltUserIdent (UserIdent x) (UserIdent y) =
  x < y

type CommentIdent
  = CommentIdent Int

type alias Comment =
  { ident : CommentIdent
  , ...
  }

ltCommentIdent : CommentIdent -> CommentIdent -> Bool
ltCommentIdent (CommentIdent x) (CommentIdent y) =
  x < y

Next, you might find out that you need (<=) to work as well for each identifier. So, you write another function.

type UserIdent
  = UserIdent Int

type alias User =
  { ident : UserIdent
  , ...
  }

ltUserIdent : UserIdent -> UserIdent -> Bool
ltUserIdent (UserIdent x) (UserIdent y) =
  x < y

gteUserIdent : UserIdent -> UserIdent -> Bool
gteUserIdent (UserIdent x) (UserIdent y) =
  x >= y

type CommentIdent
  = CommentIdent Int

type alias Comment =
  { ident : CommentIdent
  , ...
  }

ltCommentIdent : CommentIdent -> CommentIdent -> Bool
ltCommentIdent (CommentIdent x) (CommentIdent y) =
  x < y

gteCommentIdent : CommentIdent -> CommentIdent -> Bool
gteCommentIdent (CommentIdent x) (CommentIdent y) =
  x >= y

And then you might write another function, and another. Usually one of two things happens. You either end up writing a slew of boilerplate functions, or you lie to yourself (and your team if you're working with others) and say that expressing these constraints isn't important/worth the time.

If you continue down the first path, you might recognize a pattern that all of these functions have in common: they are all unwrapping the "tag", applying the function and wrapping the value back up in the "tag".

This module abstracts away the pattern so you don't have to spend time writing the same code over and over.

The key insight is that UserIdent and CommentIdent are structurally the same. They both wrap a type—Int—with a "tag"—UserIdent or CommentIdent. So what we want is a type that carries another type and a "tag":

type Tagged tag value
  = ...

Now, what values does this type need to provide? At runtime, we don't care about what type the value was tagged with. We just care about the value itself. So, let's only provide the value:

type Tagged tag value
  = Tagged value

And how does this work for our running example? Well, we need to create a "tag" for UserIdent and a "tag" for CommentIdent.

-- It'd be nice if we could just say `type UserIdent`, but elm can't parse that.
type UserIdent
  = UserIdent

type alias User =
  { ident : Tagged UserIdent Int
  , ...
  }

-- It'd be nice if we could just say `type CommentIdent`, but elm can't parse that.
type CommentIdent
  = CommentIdent

type alias Comment =
  { ident : Tagged CommentIdent Int
  , ...
  }

If we need to compare UserIdents, we can use Tagged functions rather than needing to rewrite everything.

Tagged.map2 (<) someIdent anotherIdent