Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Id, Result, Guard

The kernel is the smallest package in the corpus and the one every other tactical package depends on. Three primitives in three files: a branded Id<T> for typed identity, an exhaustive Result<T, E> for ok/err flows, and a tiny Guard for invariant assertions. The patterns built on top of ddd-core@AggregateRoot, @ValueObject, @Entity, @DomainService, @Specification — all use these three primitives in their public signatures. Get the kernel right and every downstream pattern composes; get it wrong and every fix cascades.


What ddd-core Reifies

The kernel reifies three things every tactical DDD codebase needs and TypeScript does not provide out of the box.

Typed identity. A Customer.id and an Order.id are both string at runtime; the compiler does not know they are different at the type level. A function that accepts a customer id will gladly accept an order id, and the bug shows up in production as the system charged the wrong card. The kernel's Id<T> is a phantom-type-branded string — runtime identical, compile-time distinct — so the function signature function findCustomer(id: CustomerId) refuses any string that has not been declared as a customer id.

Exhaustive failure. TypeScript's exception model is structurally unsound: function foo(): number may throw and the type signature does not say so. The kernel's Result<T, E> is the alternative — every function that can fail returns either { ok: true, value } or { ok: false, error }, and the caller must pattern-match on result.ok to extract either branch. The exhaustive check is the type system enforcing that every error is handled. Combinators like map and mapErr chain Results without unwrapping.

Typed invariant assertions. Aggregate roots and value objects enforce invariants — the email must be non-empty, the count must be in range, this reference must be defined. Without a shared utility, every aggregate writes its own if/throw boilerplate. Guard provides the three most common assertions (nonEmptyString, inRange, defined) as TypeScript assertion functions — the typed kind, where a successful assertion narrows the type for the rest of the function body.


Id — Branded Identity

id.ts declares the brand:

declare const __dddIdBrand: unique symbol;

export type Id<T extends string> = string & { readonly [__dddIdBrand]: T };

export function id<T extends string>(_brand: T, value: string): Id<T> {
  if (!value || value.length === 0) {
    throw new Error('Id<T> requires a non-empty string value');
  }
  return value as Id<T>;
}

The __dddIdBrand symbol is unique and lives in module scope — nothing outside id.ts can produce a value of type Id<T> without going through the id() factory. The factory throws on empty input, returns a string cast to Id<T> (the cast is the one place the brand is applied; afterwards the value is typed). At runtime, the value is just a string; at compile time, it carries the brand T.

In an invented Subscription context:

import { id, type Id } from '@frenchexdev/ddd-core';

export type CustomerId    = Id<'Customer'>;
export type SubscriptionId = Id<'Subscription'>;
export type BillingPlanId  = Id<'BillingPlan'>;

const c: CustomerId    = id('Customer', 'cu_abc');
const s: SubscriptionId = id('Subscription', 'sub_xyz');

function findCustomer(id: CustomerId): Customer { /* ... */ }
findCustomer(c);                  // ok
findCustomer(s);                  // TS2345 — SubscriptionId not assignable to CustomerId
findCustomer('cu_abc' as string); // TS2345 — string not assignable to CustomerId

The third call illustrates why the brand matters: even passing a string with the right shape is a type error, because the value did not go through the id('Customer', ...) factory and so does not carry the brand. The only way to make a CustomerId is to ask the factory for one, which means there is a single audit point if a brand needs to evolve.


Result<T, E> — Exhaustive ok/err

result.ts declares the union and five combinators:

export type Result<T, E> =
  | { readonly ok: true;  readonly value: T }
  | { readonly ok: false; readonly error: E };

export function ok<T>(value: T): Result<T, never>     { return { ok: true,  value }; }
export function err<E>(error: E): Result<never, E>    { return { ok: false, error }; }
export function isOk<T, E>(r: Result<T, E>): r is { ok: true;  value: T }  { return r.ok; }
export function isErr<T, E>(r: Result<T, E>): r is { ok: false; error: E } { return !r.ok; }
export function map<T, U, E>(r: Result<T, E>, fn: (v: T) => U): Result<U, E> { /* ... */ }
export function mapErr<T, E, F>(r: Result<T, E>, fn: (e: E) => F): Result<T, F> { /* ... */ }

The crucial design choice is Result<T, never> for ok and Result<never, E> for err. The never parameter is what makes type inference flow correctly: ok('hello') has type Result<string, never>, which is assignable to any Result<string, E> because never is the bottom type. The same trick on err makes error branches flow without the consumer having to annotate the success type.

The kernel's contract is exhaustive narrowing. A function that returns Result<Customer, NotFoundError | ValidationError> forces the caller to handle three states: a customer, a not-found, a validation failure. TypeScript's discriminated-union narrowing makes the pattern ergonomic — after if (r.ok) the value branch is typed, after if (isErr(r)) the error branch is typed.

A typical aggregate-method return:

function startSubscription(plan: BillingPlanId): Result<SubscriptionStarted, BillingPlanInactive | CustomerSuspended> {
  if (!planIsActive) return err({ kind: 'BillingPlanInactive', planId: plan });
  if (customerSuspended) return err({ kind: 'CustomerSuspended', customerId: this.customerId });
  return ok(new SubscriptionStarted(/* ... */));
}

The caller cannot ignore the error branch — TypeScript refuses any code path that does not handle it.


Guard — Typed Invariant Assertions

guard.ts is the smallest of the three files. GuardError is a typed exception class; Guard is a static object with three assertion functions:

export const Guard = {
  nonEmptyString(value: unknown, fieldName: string): asserts value is string { /* throws or narrows */ },
  inRange(value: number, min: number, max: number, fieldName: string): void  { /* throws */ },
  defined<T>(value: T | undefined | null, fieldName: string): asserts value is T { /* throws or narrows */ },
};

The TypeScript asserts clauses are the magic. Guard.nonEmptyString(value, 'email') succeeds → the value is narrowed to string for the rest of the function; fails → throws GuardError. The caller never writes if (typeof value === 'string') again; the assertion does it.

import { Guard, GuardError } from '@frenchexdev/ddd-core';

function emailFromString(raw: unknown): Email {
  Guard.nonEmptyString(raw, 'email');
  // `raw` is now narrowed to `string`
  if (!raw.includes('@')) throw new GuardError('email: must contain @');
  return raw as Email;
}

Guards are typically used at value-object constructors and at aggregate-method entry points — the boundaries where the type signature says "this is a string" but the invariant says "this is a non-empty string". A failed Guard throws; the caller, if they prefer the Result style, wraps the call in a try/catch that re-emits as err(...). Two ergonomic styles, one shared definition of what counts as a valid input.


The ddd-core triplet at this milestone

ddd-core has analyzer and codegen siblings (ddd-core-analyzer, ddd-core-codegen). The codegen is the one already shipped — withDddBanner (used by every other codegen template in the corpus) lives there, and is the contract surface every emitted file inherits. The analyzer carries the diagnostics primitives (Diagnostic type) the hand-written analyzers (Domain Event, Event Bus) emit.

The kernel itself does not need an analyzer of its own — the types it exports do the work. A CustomerId that ends up as OrderId is a type error; a Result whose error branch is forgotten is a type error; a guard that throws is a runtime error with a typed name. The kernel's discipline is in TypeScript itself.


ddd-core is the package every tactical pattern depends on.

  • @AggregateRoot returns Result<T, E> from its mutating methods.
  • @ValueObject uses Guard in its constructor to enforce invariants at birth.
  • @Entity carries an Id<T> as its identity primitive.
  • @DomainService typically returns Result to express cross-aggregate failures.
  • @Specification wraps boolean predicates; the kernel's Result is the natural carrier for compound specification outcomes.

Back to the series index.

⬇ Download