Photo by Earl Wilcox on Unsplash

Domain modeling for frontend applications using TypeScript

Osman Cea
10 min readMar 3, 2022

--

Thanks to Rafael Poveda and Abel Fernández for proof reading this article ❤️

One of the most rewarding things about using TypeScript instead of plain old JavaScript for writing frontend applications, is the ability types give us to model the business in terms of code. Sure, type-safeness and code hinting — which are two benefits we get from using a statically typed language like TypeScript — , have a strong case for improving the quality of your codebase and developer experience correspondingly. In my experience building applications with JavaScript and TypeScript, what I’ve found most value in when using types, is domain modeling.

Good domain modeling not only serves as great documentation: it also brings the business closer to the developers. When developers become business experts, incidental complexity decreases, as they have a better understanding of what they’re trying to solve through software. Good domain modeling is only possible when developers understand the business.

These ideas around domain modeling come from domain-driven design. This article by Martin Fowler is a good starting point if you want to learn more about it.

Regarding big old large business applications, most times your business logic will be in the backend, however there’s much value in describing how the business entities interact in your frontend.

We at Cornershop by Uber have been adopting TypeScript these past few years for all of our frontend projects, and it has paid significant dividends in terms of maintainability, which is something that you really want to strive for in large growing teams.

In this article, we’ll explore some techniques around domain modeling using TypeScript. Without further ado, let’s go!

The to-do app

We’re going to use a to-do app as an example. We’re not going to write a to-do app though, we’re just exploring the domain of a list of to-dos and trying to model it through defining its relevant types and that’s it.

I’ll assume you’re all familiar with to-do apps. If you’re not, feel free to visit https://todomvc.com/.

For most to-do apps in TypeScript, you’ll probably define an interface for a to-do as the following:

interface ToDo {
id: string
title: string
completed: boolean
}

You’ll probably also refer to to-dos individually but also as a list, so you could either just use a literal type Array<ToDo> to describe a list of to-dos, or even better, define a type alias for the list itself, since it hides the implementation details of what type is being used under the hood:

type ToDos = Array<ToDo>

Here we’re using a native Array type, but we could be using our own custom array-like data structure and we should still be referring to to-dos as just ToDos in the rest of our codebase. For example in a React application:

import React from 'react'
import type { ToDos } from '../path/to/todos'
interface ToDoProps {
todos: ToDos
}
const ToDoList = (props: ToDoListProps) => {
return <>{/* etc... */}</>
}
export default ToDoList

Regardless of the semantics of what kind of list you’re using, having a type alias for ToDos helps with refactoring and maintainability, and encourages maintaining a ubiquitous language. We’ll talk about this in a minute.

Defining types as the business requires it.

Let’s go back to our definition of ToDo:

interface ToDo {
id: string
title: string
completed: boolean
}

What we can infer from this interface is that we have two types of to-dos: completed to-dos and uncompleted to-dos. Whether we refer to them as different concepts or not depends on how the business in real life treats these as two different things.

If our business experts refer to completed to-dos and uncompleted to-dos as two different concrete things, our current definition of the ToDo type is misaligned with the reality of the business. This is where the concept of the ubiquitous language emerges in domain-driven design. I’m not going to explain it thoroughly but in short: all company members should understand the same thing when talking about a concept. When one business area (Engineering, for example) has a different understanding of a concept from the rest of the company, communication suffers. Having a common language for everybody solves this problem.

So a ToDo is actually either a CompletedToDo or an UncompletedToDo. We can model this in TypeScript using a union type (also known as sum type):

type ToDo = CompletedToDo | UncompletedToDo

This definition of a ToDo makes more sense with our hypothetical business definition. But how do these types look?

There are multiple ways to implement these new types using TypeScript and I’m going to showcase all of them. However, some of these implementations are not practical for this particular example. My advice is to choose what makes more sense to you depending on your problem.

Using literal types

The most straightforward way to define our new types would be just defining each type separately:

type ToDo = CompletedToDo | UncompletedToDo
type ToDos = Array<ToDo>
interface CompletedToDo {
id: string
title: string
completed: true
}
interface UncompletedToDo {
id: string
title: string
completed: false
}
export type { ToDo, ToDos, CompletedToDo, UncompletedToDo }

Notice how a CompletedToDo will always have its completed property as true and an UncompletedToDo as false.

The downside of this implementation is that we’re repeating ourselves with the common properties between the completed and uncompleted to-dos. We can solve this by extending a base to-do type in three different ways.

Using extends

TypeScript has an extends keyword that can be used to define an interface that extends from another type or interface:

type ToDo = CompletedToDo | UncompletedToDo
type ToDos = Array<ToDo>
interface BaseToDo {
id: string
title: string
completed: unknown
}
interface CompletedToDo extends BaseToDo {
completed: true
}
interface UncompletedToDo extends BaseToDo {
completed: false
}
export type { ToDo, ToDos, CompletedToDo, UncompletedToDo }

We’ve defined a BaseToDo type that has all the common properties for both our completed and uncompleted to-dos and then we use it as a base for creating specialized versions.

In this case the BaseToDo type should remain private, since it’s just serving an auxiliary purpose.

Notice how the BaseToDo[’completed’] has a type of unknown. Personally, I prefer to use unknown in this case instead of boolean, mainly because it’s a way to indicate that a concrete type should be specified for whatever type extends BaseToDo. If we decide to extend from BaseToDo later on and we forget to override completed, TypeScript will let us know about it. In most cases we can get away with just boolean or by simply excluding it from the base definition. As I mentioned earlier, this is just a personal preference.

TypeScript is smart enough to understand what the type of ToDo['completed'] is from the union of CompletedToDo and UncompletedToDo.

The inferred type of todo.completed is the union of true and false, which is just a boolean.

Using an intersection type

Another way to implement this is using an intersection type (also known as product type):

type ToDo = CompletedToDo | UncompletedToDo
type ToDos = Array<ToDo>
interface BaseToDo {
id: string
title: string
completed: unknown
}
type CompletedToDo = BaseToDo & {
completed: true
}
type UncompletedToDo = BaseToDo & {
completed: false
}
export type { ToDo, ToDos, CompletedToDo, UncompletedToDo }

There is a subtle (but huge) difference between using an intersection type and extending an interface. When using an intersection type, we’re trying to find the intersection between two types, or two sets, as types are just sets of possible values (all numbers, all strings, anything, etc.). This intersection could actually be empty. Consider the following type definition:

type Nonsense = { completed: true } & { completed: false }

If we inspect Nonsense we’ll find out its type is never.

An empty intersection

The intersection between true and false is actually empty: false is not a subset of true, so there’s never going to be an object where completed is both true AND false.

In the case of CompletedToDo, it is the intersection between a set of objects where completed is unknown, which is basically the same as any (in other words, all the types) and a set where completed is true, so the intersection is all the objects where completed is actually true. Same thing applies for UncompletedToDo.

Conversely, when extending an interface, you can override the initial type so we don’t really care about the type of the properties we are overriding from the base type. In our case using either an interface or an intersection type yields similar results, but it is a difference nonetheless that you have to keep in mind.

Using conditional types

Last but not least, we can use conditional type expressions to add some logic when defining our types. For our particular case this is kind of overkill, but it is something that you might find useful given a more complex use case.

In order to showcase the usage of conditional types we’ll add a new requirement for our to-do application. After some time — an hour, a day, whatever — a completed to-do should remain completed, hence it cannot be modified. Let’s say that we’re going to represent this with a boolean property in our model, called final, which will be true whenever a to-do cannot be further edited.

Our initial ToDo model could look something like this:

interface ToDo {
id: string
title: string
completed: boolean
final: boolean
}

There’s this famous talk by Richard Feldman called “Making Impossible States Impossible” — which I highly recommend watching — that claims this is bad data modeling since now we have a possible state that is impossible: it doesn’t make sense for a to-do to be not completed and final. This is something that happens a lot when we’re modeling states with boolean flags, there are combinations that might have no sense in the real world.

For our particular requirement, it would make more sense to model the status of our to-do with a union type or an enumerator, as both basically describe a finite set of possibilities. We could do something like:

enum Status {
Uncompleted = 'UNCOMPLETED',
Completed = 'COMPLETED',
Final = 'FINAL',
}

And then define our ToDo as the following:

type ToDo {
id: string
title: string
status: Status
}

Although is not exactly the same, Status could be also be defined as the union type 'UNCOMPLETED' | 'COMPLETED' | 'FINAL'.

For the sake of demonstration, let’s say that we must use boolean flags because changing our ToDo definition would require a larger refactor and we can’t tackle it right now.

To be fair we could use any of the methods mentioned previously and it would be completely fine:

type ToDo = CompletedToDo | UncompletedToDo | FinalToDo 
type ToDos = Array<ToDo>
interface BaseToDo {
id: string
title: string
completed: unknown
final: unknown
}
type CompletedToDo = BaseToDo & {
completed: true
final: false
}
type UncompletedToDo = BaseToDo & {
completed: false
final: false
}
type FinalToDo = BaseToDo & {
completed: true
final: true
}
export type {
ToDo,
ToDos,
CompletedToDo,
UncompletedToDo,
FinalToDo,
}

But for whatever reason we like our Status and it’s a concept that we want to introduce into the codebase as soon as possible. Using conditional types we can basically compute the types for completed and final based on our Status. In order to do this we have to parameterize the definition of all of our types. We can do this in TypeScript by using generics.

We want our ToDo type to look something like this:

type ToDo =
| UncompletedToDo
| CompletedToDo
| FinalToDo

Where:

type UncompletedToDo = BaseToDo<Status.Uncompleted>
type CompletedToDo = BaseToDo<Status.Completed>
type FinalToDo = BaseToDo<Status.Final>

So basically now BaseToDo depends on a parameterized type S:

interface BaseToDo<S> {}

Remember that we said that types are basically sets? In order to parameterize BaseToDo correctly, S has to be a subset of Status. In our particular case: a subset with all of the members of Status. We can use the extends keyword to when defining our generic type to signal this:

interface BaseToDo<S extends Status> {}

TypeScript knows that when we use the generic type S inside our definition of BaseToDo, it is actually a member of the Status type. With this in place, we’re ready to use conditional types for computing the types of completed and final.

I’ll just put the type definition here and explain what’s going on later:

interface BaseToDo<S extends Status> {
id: string
title: string
completed: S extends Status.Completed | Status.Final
? true
: false
final: S extends Status.Final
? true
: false
}

Let’s check the definition for completed. Here the extends keyword is used to check if S is a subset of the Status.Completed | Status.Final union type or in other words, any member of Status minus Status.Uncompleted. If that’s the case, then the type for completed is true. Otherwise it must be false. Remember that we’re talking about types here, not actual values.

An equivalent way of expressing the difference between Status and Status.Uncompleted is by using the utility built-in type Exclude, like this Exclude<Status, Status.Uncompleted>.

The same thing applies to final. If S is a member of the union type with just a single Status.Final member (hence, if S is Status.Final), the the type of final should be true, otherwise it should be false.

TypeScript is smart enough to understand the computed type from our usage of BaseToDo:

A CompletedToDo can never have a final property of true

If you’d prefer not to have these expressions inlined inside your interface, you can just extract them into more types:

type Completed<S extends Status> =
S extends Status.Completed | Status.Final
? true
: false
type Finalized<S extends Status> =
S extends Status.Final
? true
: false
interface BaseToDo<S extends Status> {
id: string
title: string
completed: Completed<S>
final: Finalized<S>
}

Our final example would look something like this:

type ToDo =
| CompletedToDo
| UncompletedToDo
| FinalToDo
type ToDos = Array<ToDo>
enum Status {
Uncompleted = 'UNCOMPLETED',
Completed = 'COMPLETED',
Final = 'FINAL',
}
interface BaseToDo<S extends Status> {
id: string
title: string
completed: S extends Status.Completed | Status.Final
? true
: false
final: S extends Status.Final
? true
: false
}
type CompletedToDo = BaseToDo<Status.Completed>
type UncompletedToDo = BaseToDo<Status.Uncompleted>
type FinalToDo = BaseToDo<Status.Final>
export type {
ToDo,
ToDos,
CompletedToDo,
UncompletedToDo,
FinalToDo,
}

Again, for our particular example this might be overkill, but it’s something worth knowing that you can apply whenever it makes sense.

And that’s all folks. I hope you find a use for these techniques around domain modeling. If you liked the content, don’t forget to share it!

You can find me on Twitter @daslaf.

Beware: I mostly tweet JavaScript rants in Spanish.

--

--