GIVE DATA
A DOMAIN.
@domain-first/types
Domain data is usually a plain object that anyone can build wrong and change anywhere. Types turns a schema into a class: valid on creation, immutable, with behavior on it.
Data without rules.
In most TypeScript codebases domain data is a bag of strings and numbers. A type describes its shape at compile time and disappears at runtime. The rules live somewhere else.
Nothing stops an invalid value. type Email = string accepts any string. Whether it was validated depends on the path it took to get here.
Every layer checks again β or trusts. Without a guarantee, the same validation repeats in handlers, services and tests, or gets skipped and hoped for.
Anyone can change anything. A plain object can be mutated far from where it was created, bypassing the rules it was validated against.
Behavior drifts away from data. The rules about an order end up in helpers and services, not on the order. Schema, inferred type and functions are three things to keep in sync.
Make the schema the class.
One schema is the single source of truth. Types turns it into a base class, and that class becomes the only way to hold such a value. If you have an Email, it is valid β you don't need to check it again.
Works with any Standard Schema library: Zod, Valibot, ArkType and more.
Say what it is.
Thatβs the code.
First the idea, in the words you would use with a teammate. Then the code, right next to it. Read them side by side: the code you write is the idea, almost word for word.
01βI need a domain type for money: an amount and a currency.β
class Money extends domainType(z.object({
amount: z.number().nonnegative(),
currency: z.enum(['USD', 'EUR']),
})) {02βIt should convert itself to minor units, cents for the payment provider.β
get minorUnits() {
return Math.round(this.amount * 100);
}
}03βAnd I need a domain type for a balance.β
class Balance extends domainType(z.object({04βOh, right: the balance holds exactly that money.β
money: z.instanceof(Money),
})) {}05βSo a balance of $12.50 is 1250 in minor units. And it is never invalid.β
const balance = Balance.of({
money: Money.of({ amount: 12.5, currency: 'USD' }),
});
balance.money.minorUnits; // 1250
Money.of({ amount: -5, currency: 'USD' }); // TypeParsingError5 sentences. 5 pieces of code. Nothing in the code that the idea didnβt ask for. No separate schema, type and helpers to keep in sync. Imports are omitted.
Start with a schema.
npm i @domain-first/typesInstall it next to your Standard Schema validator. Updates with of(), recursive models and the exact edges are in the specification.
API specification βEvery method, error and constraint.
README on npm βGuide with more examples.
Building blocks βWhere domain types live in a project.