Value Types, or Value Objects, are core components of OOP design inheriting principles from Domain Driven Design, like entities and agregates. More often than not, entities have an identity: a unique domain identifier. They usually have a id
field. Examples includes Users, Invoices, etc. Their content only doesn’t differenciate enought between two entities.
On the other hand, Value Types, don’t have a specific identity, but are identified by their content. It’s not who they are, but what’s their content. Like and Address, or an Email Address. They can introduce more semantic to your code, increase the safety, maintainability and testability. All this comes at the expanse of some degree of complexity. Especially in hydration and serialization scenarios.
For a lot of Value Types, they encapsulate a primitive value, wrapped and protected by domain specifications. Some are specific to a domain, and other cover more general use cases. In my opinion, introducing more complexity for domain Value Types is alright, but it’s not for the generic ones. It’s especially frustrating to have complex Value Type, when no behaviour is attached to theme.
How would you impement a simple, yet generic Value Type in Typescript?
Let’s dive into an example, from a previous article, where we created a Window Iterable.
class AtLeastOneWindowIterable<T> implements Iterable<T[] | undefined> {
protected constructor(
private readonly values: readonly T[],
private readonly size: number
) {}
static of<U>(values: readonly U[]) {
return {
by: (size: number) => new AtLeastOneWindowIterable(
values,
size,
),
};
}
}
What if we increased the security by introducing a Value Type that only allow positive integers for the window size.
Let’s start with the type:
type StrictlyPositiveInt = number & { __name: 'StrictlyPositiveInt' };
Adding the & { __name: string }
is common practice to create “Branded Types”.
It prevents TS from keeping the structural equivalence between a number
and StrictlyPostiveInt
. If that the case, we can’t simply assign a number
. The following won’t work, and that’s exactly our intention:
const a: StrictlyPositiveInt = 3;
// 〰️ Type 'number' is not assignable to type 'StrictlyPositiveInt'.
We need a constructor:
function StrictlyPositiveInt(v: any): StrictlyPositiveInt {
const n = Number(v);
if (!Number.isFinite(n)) throw new Error(`${v} is not a number`);
if (Math.round(n) !== n) throw new Error(`${n} is not an integer`);
if (n <= 0) throw new Error(`${n} must be positive`);
return n as StrictlyPositiveInt;
}
If you ever used zod, that’s equivalent to
import { z } from 'zod';
const StrictlyPositiveInt = z.number().int().min(1).brand('StrictlyPositiveInt');
type StrictlyPostiveIntT = z.infer<typeof StrictlyPositiveInt>;
I encourage you to read their page on branded types.
Using it, becomes quite easy:
class AtLeastOneWindowIterable<T> implements Iterable<T[] | undefined> {
protected constructor(
private readonly values: readonly T[],
private readonly size: StrictlyPositiveInt
) {}
static of<U>(values: readonly U[]) {
return {
by: (size: number) => new this(
values,
StrictlyPositiveInt(size),
),
};
}
}
Notice here, that the Iterable
encapsulates the usage of the Value Type. In my opinion there are few reasons to expose internal rules to the outside world. From experience, having entities responsible for creating their Value Types is quite maintainable, even for more complex scenarios.
The only added complexity here comes from the exceptions. How you handle it will depend on your use cases. But usually, I just don’t and prefer to let it bubble up.
Now, let’s assume for a minute, that we need to send this StrictlyPositiveInt
over the wire. No fuss or worries. We simply stringify it.
JSON.stringify(StrictlyPositiveInt(32_000))
Since, our Value Type, is only some type decoration, and runtime validation, there is no complexity. And since, our Iterable
takes a number
as input, the hydration is also painless. Win - Win!
Branded Types, Value Types, Value Objects. There are a ton of names for a simple logic encapsulation. Do you use it? How do you name it? Let me know.