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
string
Title:
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 Guid
2
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
string
Title:
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{ task with State = state }
| Editor -> Some | 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
Task
)Administrator
and
nothing elsei.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; string;
Title:
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
{ permission = CreateTask user.Id }
| Administrator -> Some
| Editor
| Viewer -> None
let getEditTaskAuth (user: User) : Option<Auth<EditTask>> =
match user.Role with
| Administrator{ permission = EditTask user.Id }
| Editor -> Some | 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.
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
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.
The examples are given in F#.↩︎
Globally Unique Identifier↩︎
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!↩︎
SOLID principles: https://en.wikipedia.org/wiki/SOLID↩︎
For a related, security centered and more thorough presentation, see Capability-based security↩︎