Encoding our domain in the type system

8 June 2023

In a statically typed language, the type of every expression (by declaration or inference) is known at compile time. If the types of arguments and return values don’t match function / operator types, the program won’t compile. If fail fast is a good principle to follow, then it follows that static type checking is beneficial because we don’t have to wait until we run the program to eventually find errors that can otherwise be found by the compiler. And since the compiler can check the correctness of expressions at the type level, it follows that the more we can encode our problem domain in the type system, the more we can leverage the compiler to verify the soundness of our solution.

Let’s look at a minimal but hopefully sufficient example to demonstrate the idea. Suppose we are building a task management application from the following specification.

We might start with the following simple types:1

type Role =
    | Administrator
    | Editor
    | Viewer

type User = { Id: Guid; Name: string; Role: Role }

type State =
    | ToDo
    | InProgress
    | Done

type Task =
    { Id: Guid
      Title: string
      State: State
      Assignee: Option<Guid> }

This seems reasonable, but with these types it is possible that one could assign a User.Id to a Task.Id, or vise-versa. Further, it’s not entirely obvious what the Assignee’s Guid2 type refers to in our domain. With a very minor modification we can tighten our types so that Id assignments that must never happen are impossible to perform:

type UserId = UserId of Guid

type User = {Id: UserId; Name: string; Role: Role}


type TaskId = TaskId of Guid

type Task =
    { Id: TaskId
      Title: string
      State: State
      Assignee: Option<UserId> }

Now the compiler will prevent us from assigning a TaskId to a UserId and vise-versa because they are different types. We have completely segregated Ids of our different domain types and have introduced more of our domain specific language into the type system, making the code more semantic relative to our domain. For example, it is now absolutely clear that Asignee references a User.

Now, what might our functions look like? From our little specification, we know that only administrators should be able to create a Task. And that both administrators and editors can change a Task’s state. We might start with the following functions:

let createTask (user: User) (title: string): Option<Task> =
    match user.Role with
    | Administrator ->
        Some
            { Id = TaskId(Guid.NewGuid())
              Title = title
              State = ToDo
              Assignee = None }
    | Editor -> None
    | Viewer -> None

let changeState (user: User) (state: State) (task: Task) : Option<Task> =
    match user.Role with
    | Administrator
    | Editor -> Some { task with State = state }
    | Viewer -> None

These functions can be called with any User. For the createTask function, if the user is an Administrator, the function will return a Task (wrapped in an Option type); otherwise it will return None to signal failure.3 For the changeState function, if the user is an Administartor or an Editor, the function will return a Task with an updated state. If the user is a Viewer, the function will return None.

Only at runtime can we know if the functions will fail or succeed, because only at runtime will we know the value of User.Role. This means that we will need to write runtime tests to assert the expected behavior is (and will continue to be) what is expected as prescribed by the specification. Further, the functions are handling two concerns: authorization and creation or state change of a Task; a sad “S” in SOLID4.

We can do better by leveraging the type system. Lets reify the roles, and remove the User.Role attribute:

type Administrator = Administrator of UserId
type Editor = Editor of UserId
type Viewer = Viewer of UserId

type User = {Id: UserId; Name: string}

In the createTask function we replace the User parameter with the Administrator

let createTask (_: Administrator) (title: string): Task =
    { Id = TaskId(Guid.NewGuid())
      Title = title
      State = ToDo
      Assignee = None }

Better! Now the function

  1. Has a single responsibility (to create a Task)
  2. Never returns a failure case due to lack of authorization because
  3. It can only ever be called with an Administrator and nothing else

i.e., we have encoded a specification constraint into the type system, so the compiler will not allow us to do something prohibited by the spec!

You will have noticed that the Administrator argument is never used (hence the _ parameter name). This may seem odd, but that’s fine because the purpose of that parameter has changed from a information-providing value to an information-providing type. Tomorrow we might again use the Administrator value if, say, a requirement came to add an “author” to the Task. e.g.,

type Task = {
  Id: TaskId; 
  Title: string; 
  State: State;
  Assignee:Option<UserId>; 
  Author:Administrator
}

let createTask (admin: Administrator) (title: string): Task =
    { Id = TaskId(Guid.NewGuid())
      Title = title
      State = ToDo
      Assignee = None 
      Author = admin }

Let’s now update the changeState function, which should only be called by users with either the administrator or editor roles. Rather that trying to encode this by having a role-type (or roles) function parameter as we did before, let’s try something a bit different.

Let’s introduce an Authorization service / module dedicated to figuring out authorization requirements for our operations. The service will have functions to get Auth authorization types required by our domain functions, which will now look like so:

let createTask (_: Auth<CreateTask>) (title: string) : Task =
    { Id = TaskId(Guid.NewGuid())
      Title = title
      State = ToDo
      Assignee = None }

let changeState 
    (_: Auth<EditTask>) 
    (state: State) 
    (task: Task) : Task = { task with State = state }

Now these functions don’t need to know or care about user roles. Instead they now say “give me someone that is authorized to create a task / edit a task”.

Let’s reintroduce the Role type and add the Auth type definitions:

type Role =
    | Administrator
    | Editor
    | Viewer

type User = {Id: UserId; Name: string; Role: Role}

type Auth<'p> =
    private
        { permission: 'p } // private constructor...

    member this.Permission = this.permission // but public access attribute.

type CreateTask = CreateTask of UserId
type EditTask = EditTask of UserId

Note that the Auth type data constructor is private, which means that it can only be called within the authentication module. However the Permission attribute is made public to be able to access the private permission value.

Now, to generate the Auth<CreateTask> type:

let getCreateTaskAuth (user: User) : Option<Auth<CreateTask>> =
    match user.Role with
    | Administrator -> Some { permission = CreateTask user.Id }
    | Editor
    | Viewer -> None

let getEditTaskAuth (user: User) : Option<Auth<EditTask>> =
    match user.Role with
    | Administrator
    | Editor -> Some { permission = EditTask user.Id }
    | Viewer -> None

So now we have an Authorization module in charge or generating instances of the Auth type. This module knows about user Roles, and the domain rules to generate authorizations. We also have a Task module that knows about tasks and about the access requirements to invoke each function, but knows nothing about user roles or how the Auth values are generated.

Closing

Here I have presented a very minimal (but hopefully useful) example to demonstrates some of the power of statically typed languages; specifically how we can leverage the type system to encode our domain with types, and thus have the compiler verify that our implementation adheres to the specification.5 Doing so

  1. reduces the number of tests needed to be written to verify correctness at runtime,
  2. provides developers with quicker feedback about the correctness of the implementation, and, more importantly,
  3. allows us to better constrain our implementation to those actions that are allowed by the domain specification, and only those (more on this later).

There are many ways to skin a cat, and many ways to encode the same essential structure. This was just one simplified approach to a specific sample use case. Hopefully this little example inspires you to find other and better ways to encode your domain in the type system.

You can find the full source code here.


  1. The examples are given in F#.↩︎

  2. Globally Unique Identifier↩︎

  3. We use the Option<'a> type here (instead of a Result<'a, 'b>) for simplification purposes. The main point is that we need to encode the possibility of failure, and Option is enough. We certainly don’t want to throw an exception!↩︎

  4. SOLID principles: https://en.wikipedia.org/wiki/SOLID↩︎

  5. For a related, security centered and more thorough presentation, see Capability-based security↩︎

Spread the Word