Domain modeling in Typescript
Typescript provides a range of tools for building rich domain models. However there are a number of approaches to doing this, and some tricky challenges to work around.
What success looks like
The key challenges any approach has to address are:
- Serialization / deserialization: Data from the persistence and transport layers is untyped, and needs to be brought into the ‘type safe zone’
- Handling aggregates, value objects, and lists
- Supporting union types, polymorphism, and inheritance
- Immutability — while this isn’t a requirement, the benefits are enough that an approach that supports creating immutable models is preferable
Options and trade-offs
Interfaces
The obvious starting-point to modeling a domain from data that needs to be serialized is to define interfaces for the data, and cast the raw data to these interfaces:
- On the positive side defining these interfaces is better than not defining them, as the tooling has more information and the compiler can catch obvious errors like trying to access a field that does not exist.
- However this approach does not support immutability and gives no type safety — incorrect data will be cast without an error, and this will create an unidentified mismatch between what the type system thinks is the shape of the object, and its real shape. This will only be identified, if at all, when the code tries to do something like access a non-existent property.
- Casting to an interface does not add any functionality to the domain objects, so they are anemic by definition.
interface IPerson {
name: string // name theoretically can't be undefined
}const person = <Person>deserializedDataconsole.log(person.name) // No guarantee that name is defined
Builders
A commonly advocated approach is to use the builder pattern:
- Builders can do field-level validation, and can check the full object before returning it, so type safety can be guaranteed.
- Builders decouple the shape of the constructed object from the shape of the input, which has advantages but adds a lot of overhead for marshaling from untyped data.
- Builders can be used to generate immutable domain objects, but having domain methods that change the state of these objects gets very cumbersome, and having the class reference the builder is ugly.
- Builders add a lot of overhead — each domain class needs a builder defined, and the two need to be kept in sync
class Person {
readonly name: string addLastName(lastName: string): Person {
return new PersonBuilder()
.setName(`${this.name} ${lastName}`)
.getResult()
}
}class PersonBuilder {
getResult(): Person {} // Throw if name not initialized setName(name: string): PersonBuilder {} // Throw for invalid name
}const person = new PersonBuilder()
.setName(deserializedData.name)
.getResult()
Factories and mappers
This article has a good breakdown of using factories and mappers to address these challenges. This approach is more in line with DDD, but is similar to builders in terms of overhead.
Using the constructor
While there is a lot of discussion about how much complexity should be in a constructor, seeing the only role of a constructor as creating a type-safe instantiation of untyped data, with an optional update to that data, results in readable, type-safe code:
// Get the field value from the update, falls back to the item
function fallback(update?: any, item: any, field: string) {}class Person {
readonly name: string constructor(item: any, update?: any) {
if (!item) { throw new Error('Item not supplied') } this.name = fallback(update, item, 'name') if (!this.name) { throw new Error('Name not supplied') }
}
addLastName(lastName: string): Person {
return new Person(this, { name: `${this.name} ${lastName}` })
}
}const person = new Person(deserializedData)
- As with the builder, both field-level and full-object checks are supported, and type safety can be guaranteed
- Constructing complex objects can be done in one line, as opposed to a line per field with a builder or mapper
- Domain model methods that change state can use the update parameter of the constructor to do this in one line, as opposed to using a builder to re-create the full object line-by-line
- All construction and validation logic for a class is in one place
Extras—nested objects and union types
Domain driven design advocates the use of aggregates for domain modeling, and aggregates will often have nested value objects. Handling these situations with a builder or mapper gets cumbersome, but using the constructor is clean:
class Address {
readonly postalCode: string // Obvious constructor..
}class Person {
readonly address: Address
readonly name: string constructor(item: any, update?: any) {
if (!item) { throw new Error('Item not supplied') this.address = new Address(fallback(update, item, 'address'))
this.name = fallback(update, item, 'name') if (!this.name) { throw new Error('Name not supplied') }
}
}const person = new Person(deserializedData)
Union types are a great way to model ‘different types of the same thing’, and can be supported with a helper method:
class PostalAddress {
readonly postalCode: string
}class ResidentialAddress {
readonly street: string
}type Address = PostalAddress | ResidentialAddress// Takes raw address and returns constructed address
function address(item: any): Address {}class Person {
readonly address: Address
readonly name: stringconstructor(item: any, update?: any) {
if (!item) { throw new Error('Item not supplied') } this.address = address(fallback(update, item, 'address'))
this.name = fallback(update, item, 'name') if (!this.name) { throw new Error('Name not supplied') }
}
}const person = new Person(deserializedData)
I’ve been using this pattern daily for the last six months, and it’s the cleanest approach I can find for supporting immutability and guaranteeing type safety across the serialization boundary.
PS: Caveats
- While it’s possible to reshape data in the constructor, you then need a ‘deconstructor’ to reverse this process every time you want to serialize. I’ve found that maintaining the symmetry: item = new Item(JSON.parse(JSON.stringify(item)) makes for much cleaner code, at the expense of moving away from ‘pure’ DDD.
- The above approach doesn’t guarantee immutability for object and array fields. It’s possible to use a library like immutable for this, but this breaks serialization / deserialization symmetry, and for me it’s cleaner just to not write code that mutates object and array fields.
- There’s an argument that requiring full instantiation of objects makes unit test hard — ie you need to provide all the fields of the object to test any one method. I’m working around this by defining a valid stub object against which to run unit tests — any required variations to the stub object’s state can be set with the update parameter of the constructor: const newItem = new Item(stub, { name: ‘test’ })