FORM FOLLOWS DOMAIN. DOMAIN COMES FIRST.NODE.JS / REACT
SCHEMA β†’ CLASS β†’ BEHAVIOR

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.

01 / The problem

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.

01

Nothing stops an invalid value. type Email = string accepts any string. Whether it was validated depends on the path it took to get here.

02

Every layer checks again β€” or trusts. Without a guarantee, the same validation repeats in handlers, services and tests, or gets skipped and hoped for.

03

Anyone can change anything. A plain object can be mutated far from where it was created, bypassing the rules it was validated against.

04

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.

02 / The approach

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.

ANY VALIDATOR

Works with any Standard Schema library: Zod, Valibot, ArkType and more.

03 / The idea, then the code

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.

The ideaThe code you write

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' }); // TypeParsingError

5 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.

04 / Start

Start with a schema.

Terminal
npm i @domain-first/types

Install 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.