Type-Safe Value Objects in TypeScript

Hannes Petri
5 min readJan 2, 2019

--

Imagine a printing house whose business software is written in TypeScript. Somewhere in the vast codebase, there are a handful of lines of code that create a BookCover object from a Book object and an Author object:

const author = context.getAuthor();
const book = context.getBook();
const cover = new BookCover(
book.year,
book.title,
author.name,
);

This printing house happens to focus on public domain works, and as their next big publishing, a new issue of Rudyard Kipling’s classic The Jungle Book will be printed in a large number of copies. Since their last release, a few source code modifications have been made to the application, but nothing out of the ordinary. However, as the books reach the bookstores and customers are confronted with their covers, the company realises that they have made a terrible mistake…

When regular type checking is not enough

Can you spot the error? How could this happen? The explanation is as banal as it is frustrating: the arguments of BookCover’s constructor were mixed up, and the author’s name was mistakenly passed as the title of the book and vice versa. Since both arguments are of type string, the compiler didn’t catch the error.

How can these sneaky bugs be avoided? “Better tests” is of course a valid answer. However, by using the powerful tools that statically typed languages provide us with, we can catch these errors already at compile-time.

The basic idea is to create Value Objects for plain values instead of using scalars. The idea is age-old, but was explicitly defined in Eric Evans’ 2004 book and is a core concept in Domain-Driven Design. Some languages, like Scala, even have value objects built into the language. For worthwhile descriptions of the merits of using value objects, see this post by Martin Fowler and this book by Vaughn Vernon.

In the above example, employing value objects would generate the following constructor:

class BookCover {
constructor(
year: Year, // was number
authorName: Name, // was string
title: Title // was string
) {
// ...
}
// ...
}

Passing anything else than a Title for the title, and a Name for the author name, would’ve prevented the application from compiling.

Implementing value objects in TypeScript

What are these value objects from a technical point of view? How should we create them? What operations can we perform of them? And how do they bring type safety?

Of course there are several strategies for achieving roughly the same effect. In 2004, when the idea of value objects was conceived, big business applications usually meant Java, and Java always means classes. Since TypeScript inherits many of the class-related faculties of Java, it’s tempting to start creating classes for your value objects. However, here I’d like to propose a different, and in my opinion more elegant approach involving plain objects, plain functions and parametrised interfaces. Simply create a base interface called ValueObject:

interface ValueObject<T> {
type: string;
value: T;
}

Defining a new value object type is just a matter of extending the interface, overriding (that is, restricting) the type of type with a unique string identifier:

interface Name extends ValueObject<string> {
type: "NAME";
}
interface Title extends ValueObject<string> {
type: "TITLE";
}
interface Year extends ValueObject<number> {
type: "YEAR";
}

Creating a value object unfortunately becomes a rather verbose affair:

const age: Year = { type: "YEAR", value: 1895 };
const name: Name = { type: "NAME", value: "Rudyard Kipling" };

But fear not! This is easily alleviated by creating a value object creator function, similar to how action creators are used in Redux:

function yearOf(value: number): Year {
return { type: "YEAR", value };
}
function nameOf(value: string): Name {
return { type: "NAME", value };
}
const year = yearOf(1895);
const name = nameOf("Rudyard Kipling");

These creator functions can also serve as an entry point for validation. For example, a Distance value object might not permit negative numbers.

Operations on value objects

At this point, we have devised something that would’ve caused the compiler to protest against the mixing up of arguments that resulted in dozens of boxes of unsellable books. In order to further leverage value objects, a host of functions for performing various operations on them can be created according to the needs of the application. For example, a common operation is to determine whether two values are equal:

function isEqualTo<T, V extends ValueObject<T>> (
v1: V,
v2: V
) {
return v1.value === v2.value;
}

Thanks to the type parameters, the compiler will produce an error whenever the function is called on two value objects of different type:

const title = titleOf("The Jungle Book");
const name = nameOf("Rudyard Kipling");
const areEqual = isEqualTo(name, title);
^^^^^
Argument of type 'Title' is not assignable to parameter of type 'Name'.
Types of property 'type' are incompatible.
Type '"TITLE"' is not assignable to type '"NAME"'.

Similar functions, such as isLessThan, are easy to implement in a similar way. Here’s also a good opportunity to elegantly evade a nasty JavaScript oddity, where applying numerical inequalities on strings determine their lexicographical order. With the following implementation, the compiler won’t allow isLessThan to be invoked with anything but number value objects:

function isLessThan<T extends ValueObject<number>>(
v1: T,
v2: T
) {
return v1.value < v2.value;
}

If you’re already familiar with value objects, you probably miss something: immutability. One of the key advantages of value objects is their immutable structure, which prevents accidental modifications and makes passing them around and copying them completely safe. Fortunately, this is easily achieved with a small change in the ValueObject interface:

interface ValueObject<T>
extends Readonly<{
type: string;
value: T;
}> {}

Now trying to modify the value results in an error:

const title = titleOf("The Jungle Book");
title.value = "The Bungle Jook";
^^^^^
Cannot assign to 'value' because it is a read-only property.

Concluding words

I hope this text has sparked your interest in value objects, or if you were already familiar with the concept, inspired you to putting them to use in a TypeScript application. However, it’s important to stress that it would be unwise to coerce them into every component of a large codebase. Consider them as one pattern out of many, that can improve code quality and reliability when used right.

--

--