
Kamae
- 26 installs
- 50 repo stars
- Updated June 25, 2026
- iwasa-kosui/kamae-ts
Design robust server-side TypeScript with Kamae: discriminated unions, pure state transitions, Result types, schema-validated boundaries and PII protection.
About
Kamae is a functional domain-modeling ruleset for server-side TypeScript spanning discriminated unions, pure transitions, Result error handling, boundary schema validation and PII protection, loaded lazily per topic. Use it when designing TS domain models, error handling, or boundary validation.
- Detects Result and validation libraries from package.json (neverthrow/byethrow/fp-ts, zod/valibot/arktype)
- Six topic files plus a project/user/plugin rules-loading step for overrides
Kamae by the numbers
- 26 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #3,410 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/iwasa-kosui/kamae-ts --skill kamaeAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 26 |
|---|---|
| repo stars | ★ 50 |
| Last updated | June 25, 2026 |
| Repository | iwasa-kosui/kamae-ts ↗ |
What it does
Design robust server-side TypeScript with Kamae: discriminated unions, pure state transitions, Result types, schema-validated boundaries and PII protection.
Files
Kamae — Functional Domain Modeling in TypeScript
Six topic files cover the principles. Read only the file(s) relevant to the current task. The library guides under result-libraries/ and validation-libraries/ are read on demand based on the project's package.json.
Step 0: Load applicable rules
Before any other step, glob and Read rules in priority order:
1. .claude/rules/*.md (project-level overrides at the working-tree root) 2. ~/.claude/rules/*.md (user-global preferences) 3. ../../rules/defaults/*.md relative to this SKILL.md (plugin defaults)
For each file:
- Read the YAML frontmatter. Skip the rule unless
applies-toiskamaeor*. - Group by
name. For eachname, keep only the highest-tier instance (1 > 2 > 3); within a tier the lexicographically last filename wins. - Apply the body of each surviving rule throughout the remaining steps. A
library-preferencerule overrides Step 1 detection; aconventionrule shapes generated code; anoverriderule replaces guidance from a specific topic file.
If no rules are found, proceed with the plugin defaults already documented in `../../rules/defaults/`.
See `../../rules/README.md` for the rule format.
Step 1: Detect project libraries
Read package.json once. Note which Result library and validation library are present:
- Result libraries — match the first present in priority
neverthrow>byethrow>fp-ts>option-t. Load the matching guide under `result-libraries/` when error-handling is in scope. - Validation libraries — match the first present in priority
zod>valibot>arktype. Load the matching guide under `validation-libraries/` when boundary or branded-type work is in scope.
If none are present, ask the user before proceeding.
Step 2: Apply the topic relevant to the task
Each topic below is one file. Read it lazily — only the file(s) you need for the current task.
Type-Driven Domain Modeling — domain-modeling.md
Represent states with discriminated unions using kind as the unified discriminant. Use type (not interface), Companion Object pattern, branded types via the project's validation library, Readonly<>, function property notation, and one-concept-per-file structure.
State Transitions — state-modeling.md
Express transitions with pure functions. Argument types constrain valid source states; return types make targets explicit. Invalid transitions become compile errors. Use assertNever for exhaustiveness.
Error Handling — error-handling.md
Treat errors as values via Result. Define error types as discriminated unions so callers branch exhaustively. Do not throw exceptions in domain code.
Boundary Defense — boundary-defense.md
Validate every external input (API requests, DB results, file/queue/env) with a schema at runtime. Trust types inside the domain. Do not use type assertions — as const and as const satisfies Type are the only allowed forms; when the type is unknown, parse through a validation-library schema instead. Apply Sensitive<T> to PII fields; the validation schema auto-wraps them.
Declarative Style — declarative-style.md
Use filter / map / reduce with companion-object predicates instead of imperative loops. Model domain events as immutable records.
Test Data — test-data.md
Define fixtures with as const satisfies Type to preserve discriminant literal types and prevent widening.
Examples
Worked end-to-end examples are in examples/. Read them only when the topic guide cites a specific example.
Applying These Principles
These are recommendations, not strict rules. Use judgment based on context. If you deviate from a principle, state the reason in a comment. Justifiable reasons include: external library requires class inheritance, immutable object creation cost is a measured performance concern, or a different pattern has been adopted by team agreement.
Boundary Defense Detailed Guide
Understanding the Limits of TypeScript's Type System
TypeScript's types are erased at compile time. Because no type information remains at runtime, the correctness of externally incoming data cannot be guaranteed by types alone.
Structural subtyping allows objects with extra properties to be assigned to types with fewer properties. This can be a source of unintended data leakage.
type LogPayload = { id: string; role: string };
const user = { id: "1", role: "admin", email: "secret@example.com" };
// Passes type check, but email is included in the log
console.log(JSON.stringify(user satisfies LogPayload));Schema-Based Validation
At external boundaries (API requests, DB results, environment variables, file reads), parse with validation library schemas at runtime.
Validation library detection: Check dependencies / devDependencies in the project's package.json and follow the guide for the matching library. If none are found, ask the user.
zod→ validation-libraries/zod.mdvalibot→ validation-libraries/valibot.mdarktype→ validation-libraries/arktype.md
The following examples use Zod syntax. See the validation library guides above for Valibot and ArkType equivalents.
import { z } from "zod";
const CreateRequestInput = z.object({
passengerId: z.string().uuid(),
pickupLocation: z.object({
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
}),
});
type CreateRequestInput = z.infer<typeof CreateRequestInput>;Use safeParse
parse throws an exception. For integration with Railway Oriented Programming, use safeParse and convert the result to a Result type.
// Convert the safeParse result to the Result type library used in the project
const parseInput = (raw: unknown): Result<CreateRequestInput, ValidationError> => {
const result = CreateRequestInput.safeParse(raw);
if (result.success) return success(result.data); // ok(), right(), createOk(), etc.
return failure({ kind: "ValidationError", issues: result.error.issues });
};Schema Factory: Automatic Validation → Result Type Conversion
The validation → Result type conversion follows the same pattern for every schema. Rather than writing it by hand each time, define a single schema factory that matches the Result type library used in the project, and auto-generate parse functions for each schema.
These factories use the Standard Schema interface (schema['~standard'].validate()), so they work with any Standard Schema-compliant library (Zod, Valibot, ArkType, etc.) without modification.
For neverthrow
import { ok, err, Result } from "neverthrow";
import type { StandardSchemaV1 } from "@standard-schema/spec";
type ValidationError = Readonly<{
kind: "ValidationError";
issues: ReadonlyArray<StandardSchemaV1.Issue>;
}>;
const schemaResult = <T>(schema: StandardSchemaV1<unknown, T>) =>
(raw: unknown): Result<T, ValidationError> => {
const result = schema["~standard"].validate(raw);
if (result instanceof Promise) throw new TypeError("Schema validation must be synchronous");
if (result.issues) return err({ kind: "ValidationError", issues: result.issues });
return ok(result.value);
};
// Usage — works with Zod, Valibot, ArkType, or any Standard Schema-compliant library
const parseCreateRequestInput = schemaResult(CreateRequestInput);
const parseRequestId = schemaResult(RequestIdSchema);
// parse: (raw: unknown) => Result<CreateRequestInput, ValidationError>
const result = parseCreateRequestInput(rawBody);For fp-ts
import * as E from "fp-ts/Either";
import type { StandardSchemaV1 } from "@standard-schema/spec";
type ValidationError = Readonly<{
kind: "ValidationError";
issues: ReadonlyArray<StandardSchemaV1.Issue>;
}>;
const schemaEither = <T>(schema: StandardSchemaV1<unknown, T>) =>
(raw: unknown): E.Either<ValidationError, T> => {
const result = schema["~standard"].validate(raw);
if (result instanceof Promise) throw new TypeError("Schema validation must be synchronous");
if (result.issues) return E.left({ kind: "ValidationError", issues: result.issues });
return E.right(result.value);
};For option-t
import { createOk, createErr, type Result } from "option-t/plain_result";
import type { StandardSchemaV1 } from "@standard-schema/spec";
type ValidationError = Readonly<{
kind: "ValidationError";
issues: ReadonlyArray<StandardSchemaV1.Issue>;
}>;
const schemaResult = <T>(schema: StandardSchemaV1<unknown, T>) =>
(raw: unknown): Result<T, ValidationError> => {
const result = schema["~standard"].validate(raw);
if (result instanceof Promise) throw new TypeError("Schema validation must be synchronous");
if (result.issues) return createErr({ kind: "ValidationError", issues: result.issues });
return createOk(result.value);
};For byethrow
import { Result } from "@praha/byethrow";
import type { StandardSchemaV1 } from "@standard-schema/spec";
type ValidationError = Readonly<{
kind: "ValidationError";
issues: ReadonlyArray<StandardSchemaV1.Issue>;
}>;
const schemaResult = <T>(schema: StandardSchemaV1<unknown, T>) =>
(raw: unknown): Result.Result<T, ValidationError> => {
const result = schema["~standard"].validate(raw);
if (result instanceof Promise) throw new TypeError("Schema validation must be synchronous");
if (result.issues) return Result.fail({ kind: "ValidationError", issues: result.issues });
return Result.succeed(result.value);
};Guidelines
- Do not hand-write validation → Result conversions for each schema. Define a single factory function and reuse it across the project
- Unify the return type of the factory to the Result type library in use
- The factory uses Standard Schema so it works with any compliant validation library (Zod, Valibot, ArkType)
- Combine with the companion object pattern to expose the schema definition and
parsefunction together:
// Works with any Standard Schema-compliant validation library
const RequestId = {
schema: RequestIdSchema,
parse: schemaResult(RequestIdSchema),
} as const;
// Usage
const id = RequestId.parse(raw); // Result<RequestId, ValidationError>Banning Type Assertions (as)
as bypasses type checking. The only permitted forms are as const and as const satisfies Type — every other as is prohibited.
When the value's type is unknown to the compiler (external input, raw data, runtime-shaped objects), the answer is always to parse it through a validation-library schema. Asserting a type with as does not give you the guarantees the type claims; parsing does.
// ❌ as bypasses validation — the type is a lie if data doesn't match
const user = data as User;
// ✅ Schema parse produces a real User
const user = UserSchema.parse(data);For Branded Types, using the validation library's brand feature eliminates the need for as. See the validation library guides for library-specific syntax (e.g., z.brand() for Zod, v.brand() for Valibot, .brand() for ArkType).
// ❌ Manual brand + as cast
type ItemId = string & { readonly __brand: unique symbol };
const ItemIdSchema = z.string().regex(/^item-\d+$/);
const parse = (raw: string): ItemId => ItemIdSchema.parse(raw) as ItemId;
// ✅ z.brand() — no as needed (Zod example)
export const ItemIdBrand = Symbol();
const ItemIdSchema = z.string().regex(/^item-\d+$/).brand<typeof ItemIdBrand>();
type ItemId = z.infer<typeof ItemIdSchema>;
const parse = (raw: string): ItemId => ItemIdSchema.parse(raw); // already ItemId typeLast-resort exception: unique symbol Branded Type factories
Projects that have not yet adopted a validation library may use as only inside a Branded Type constructor that brands an already-validated value. Treat this as a fallback to be migrated away from as soon as a validation library is introduced — it is not a permanent option.
const UserId = {
of: (value: string): UserId => value as UserId, // permitted only when no validation library is available
};When you encounter a project where this fallback is in use, prefer adding a validation library and rewriting the brand with z.brand() / v.brand() / .brand() over keeping the as.
PII Protection with the Sensitive Type
Problem
TypeScript types are erased at runtime, so marking something as PII in the type system does not prevent leakage via JSON.stringify or console.log. Even with Branded Types, the brand is lost on variable assignment.
Solution: Closure-Based Wrapper
Enclose the value in a function closure and automatically mask it during serialization.
type Sensitive<T> = Readonly<{
unwrap: () => T;
toJSON: () => string;
toString: () => string;
}>;
const Sensitive = {
of: <T>(value: T): Sensitive<T> => ({
unwrap: () => value,
toJSON: () => "[REDACTED]",
toString: () => "[REDACTED]",
[Symbol.for("nodejs.util.inspect.custom")]: () => "[REDACTED]",
}),
} as const;Integration with Validation Libraries
Automatically wrap in Sensitive at parse time. The following example uses Zod. See the validation library guides for Valibot and ArkType equivalents.
const sensitiveString = z.string().transform(Sensitive.of);
const PatientSchema = z.object({
id: z.string().uuid(),
name: sensitiveString,
email: sensitiveString,
diagnosis: sensitiveString,
role: z.string(), // not PII
});
const patient = PatientSchema.parse(rawData);
console.log(JSON.stringify(patient));
// {"id":"...","name":"[REDACTED]","email":"[REDACTED]","diagnosis":"[REDACTED]","role":"doctor"}Defense in Depth: Pino Redaction
As a backup for missed Sensitive wrapper applications, also configure redaction at the logger level.
import pino from "pino";
const logger = pino({
redact: {
paths: ["email", "*.email", "password", "*.password", "name", "*.name"],
censor: "[REDACTED]",
},
});Do Not Over-Defend Inside the Domain
Data that has been validated at the external boundary should not be re-validated inside the domain layer. Trust the types.
// Bad: redundant checks in the domain layer
const assignDriver = (waiting: Waiting, driverId: DriverId): EnRoute => {
if (waiting.kind !== "Waiting") throw new Error("Invalid state"); // the type already guarantees this
if (!driverId) throw new Error("Missing driverId"); // the type already guarantees this
return { kind: "EnRoute", passengerId: waiting.passengerId, driverId };
};
// Good: trust the types
const assignDriver = (waiting: Waiting, driverId: DriverId): EnRoute => ({
kind: "EnRoute",
passengerId: waiting.passengerId,
driverId,
});Declarative Style Detailed Guide
Array Operations
Write array transformations declaratively using filter / map / reduce. Define predicate functions in the Companion Object.
type Task = ActiveTask | CompletedTask;
const Task = {
isActive: (task: Task) => task.kind === "Active",
} as const;
// Declarative: intent is clear
const activeTasks = tasks.filter(Task.isActive);
// Imperative: you have to read the loop body to understand the intent
const activeTasks: ActiveTask[] = [];
for (const task of tasks) {
if (task.kind === "Active") activeTasks.push(task);
}Don't write redundant x is Y annotations
Predicate functions over a discriminated union don't need an explicit : x is Y return-type annotation. TypeScript 5.5+ infers the type predicate from any body that narrows on kind, and Array.prototype.filter consumes the inferred predicate. Writing the annotation falsely implies that discriminated union narrowing alone is insufficient.
// ❌ Redundant — the inferred predicate already exists
isActive: (task: Task): task is ActiveTask => task.kind === "Active",
// ✅ Let the compiler infer
isActive: (task: Task) => task.kind === "Active",The same applies to multi-state predicates: bodies built from || chains over kind or their !== … && !== … negation are all inferred correctly by TS 5.5+.
Domain Events
Generate domain events that accompany state changes as immutable records, and record them separately from the repository.
type DomainEvent = Readonly<{
eventId: string;
eventAt: Date;
eventName: string;
payload: unknown;
aggregateId: string;
}>;For detailed design of domain events (event generation responsibility, use case integration), see state-modeling.md.
Type-Driven Domain Modeling Detailed Guide
Represent State with Discriminated Unions
Define domain entity states using Discriminated Unions instead of classes. Define each state as its own type and make state-specific properties required.
// Good: Each state is an independent type. State-specific properties are required
type Waiting = Readonly<{
kind: "Waiting";
passengerId: PassengerId;
}>;
type EnRoute = Readonly<{
kind: "EnRoute";
passengerId: PassengerId;
driverId: DriverId;
}>;
type TaxiRequest = Waiting | EnRoute | InTrip | Completed | Cancelled;// Bad: Cramming all states into one type with optional properties
type TaxiRequest = {
state: string;
passengerId: string;
driverId?: string; // unclear which state this exists in
startTime?: Date; // null checks required everywhere
endTime?: Date;
};Rationale: Optional properties cannot guarantee at compile time which properties exist in which state. With Discriminated Unions, once you narrow on kind in a switch statement, you can safely access state-specific properties.
Use kind as the unified discriminant
Use kind as the discriminant property name throughout the entire project. Mixing type, status, state, etc. undermines codebase consistency.
Companion Object Pattern
Group a type definition and its related functions under an object of the same name. Branded Type validation schemas should be exposed as a schema property on the companion object, not as standalone exports.
// ❌ Standalone schema export — leaks implementation details
export const ItemIdBrand = Symbol();
export const ItemIdSchema = z.string().regex(/^item-\d+$/).brand<typeof ItemIdBrand>();
// ✅ Companion object owns the schema
const ItemIdBrand = Symbol();
const ItemIdSchema = z.string().regex(/^item-\d+$/).brand<typeof ItemIdBrand>();
export type ItemId = z.infer<typeof ItemIdSchema>;
export const ItemId = {
schema: ItemIdSchema,
parse: (raw: string) => ItemIdSchema.safeParse(raw),
} as const;type TaxiRequest = Waiting | EnRoute | InTrip | Completed | Cancelled;
const TaxiRequest = {
assignDriver: (waiting: Waiting, driverId: DriverId): EnRoute => ({
kind: "EnRoute",
passengerId: waiting.passengerId,
driverId,
}),
startTrip: (enRoute: EnRoute, startTime: Date): InTrip => ({
kind: "InTrip",
passengerId: enRoute.passengerId,
driverId: enRoute.driverId,
startTime,
}),
isActive: (request: TaxiRequest) =>
request.kind !== "Completed" && request.kind !== "Cancelled",
} as const;Use type (not interface)
Define domain types with type. The declaration merging of interface poses a risk: declaring an interface with the same name in another file silently changes the type's shape.
// Good
type User = Readonly<{
id: UserId;
name: string;
}>;
// Bad: if another file declares `interface User { hashedPassword?: string }`,
// the type changes without you noticing
interface User {
id: string;
name: string;
}Use function property notation (not method notation)
Write functions inside type definitions using function property notation, not method notation. Method notation makes parameter types bivariant, breaking type safety.
// Good: function property notation — parameters are contravariant
type TaskRepository = {
save: (task: Task) => Promise<void>;
findById: (id: TaskId) => Promise<Task | undefined>;
};
// Bad: method notation — parameters become bivariant,
// allowing a narrower implementation like save(task: DoingTask) to pass type checks
type TaskRepository = {
save(task: Task): Promise<void>;
findById(id: TaskId): Promise<Task | undefined>;
};Distinguish meaning with Branded Types
Due to structural subtyping, two string values are compatible. Apply Branded Types to IDs and values with different semantic meanings.
Validation library detection: Check dependencies / devDependencies in the project's package.json and follow the guide for the matching library. If none are found, ask the user.
zod→ validation-libraries/zod.mdvalibot→ validation-libraries/valibot.mdarktype→ validation-libraries/arktype.md
When using a validation library, define brands with its brand feature. The schema output type becomes automatically branded, eliminating the need for as casts. The following example uses Zod:
import { z } from "zod";
export const UserIdBrand = Symbol();
const UserIdSchema = z.string().uuid().brand<typeof UserIdBrand>();
type UserId = z.infer<typeof UserIdSchema>;
export const ProductIdBrand = Symbol();
const ProductIdSchema = z.string().uuid().brand<typeof ProductIdBrand>();
type ProductId = z.infer<typeof ProductIdSchema>;
// safeParse().data is already branded — no `as` cast neededFor projects not using a validation library, use the unique symbol pattern.
export const UserIdBrand = Symbol();
type UserId = string & { readonly [typeof UserIdBrand]: never };
export const ProductIdBrand = Symbol();
type ProductId = string & { readonly [typeof ProductIdBrand]: never };Ensure immutability with Readonly<>
Define domain objects with Readonly<> to prevent property reassignment. Express state changes by creating new objects.
File structure: one concept per file
Place each domain concept (type + companion object) in its own dedicated file. Catch-all files like types.ts or models.ts are prohibited — they separate types from behavior and cause circular dependencies.
// ❌ Types aggregated in types.ts, companions in separate files
// types.ts — ItemId, ItemType, Status, Priority, Item, Config, ...
// item-id.ts — ItemId companion object (imports type from types.ts)
// ✅ Split files per concept
// item-id.ts — type ItemId + const ItemId (companion)
// item-type.ts — type ItemType + const ItemType (companion)
// status.ts — type Status + const Status (companion)Barrel files (index.ts) are for re-exports only; do not define types or functions directly in them.
Error Handling Detailed Guide
Railway Oriented Programming
Use Result types to represent success and failure in the type system. Do not throw exceptions in the domain layer. For library-specific APIs, refer to the corresponding guide in result-libraries/.
Error Type Design
Define errors as Discriminated Unions so that callers can handle them exhaustively.
type AssignDriverError =
| Readonly<{ kind: "RequestNotFound"; requestId: RequestId }>
| Readonly<{ kind: "InvalidState"; currentKind: string; expectedKind: "Waiting" }>
| Readonly<{ kind: "DriverNotAvailable"; driverId: DriverId }>;Error Type Granularity
The error type returned by each use case should be specific to that use case. Stuffing everything into a common error type (AppError) makes it impossible for callers to determine from the type which errors can actually occur.
// Good: use case-specific error types
type AssignDriverError = RequestNotFoundError | InvalidStateError | DriverNotAvailableError;
type StartTripError = RequestNotFoundError | InvalidStateError;
// Bad: stuffing all errors into one type
type AppError = RequestNotFoundError | InvalidStateError | DriverNotAvailableError | ...;Composing Operations
Each step returns a Result type, and if an error occurs, subsequent steps are skipped. The composition API differs by library (neverthrow/byethrow use .andThen(), fp-ts uses pipe + chain, option-t uses flatMapForResult).
Helper Functions
Extract common validation into small functions and use them as composition steps.
// Helper return values are Result types. The specific API (ok/err, right/left, etc.) depends on the library
const ensureFound = <T>(id: RequestId) => (
value: T | undefined,
): Result<T, RequestNotFoundError> =>
value !== undefined
? success(value) // ok(), right(), createOk(), etc.
: failure({ kind: "RequestNotFound", requestId: id });
const ensureWaiting = (
request: TaxiRequest,
): Result<Waiting, InvalidStateError> =>
request.kind === "Waiting"
? success(request)
: failure({ kind: "InvalidState", currentKind: request.kind, expectedKind: "Waiting" });Error Conversion in the Controller Layer
Converting domain errors to HTTP responses is the responsibility of the Controller layer. Determine the status code based on the domain error's kind.
const toHttpResponse = (error: AssignDriverError): Response => {
switch (error.kind) {
case "RequestNotFound":
return notFound(`Request ${error.requestId} not found`);
case "InvalidState":
return conflict(`Expected ${error.expectedKind}, got ${error.currentKind}`);
case "DriverNotAvailable":
return unprocessableEntity(`Driver ${error.driverId} is not available`);
default:
return assertNever(error);
}
};Where Exceptions Are Appropriate
Do not throw exceptions in the domain layer, but exceptions are appropriate in these places:
assertNever: detecting unreachable code (programming bugs)- Unexpected infrastructure failures (e.g., DB connection loss) — delegate these to the framework's error handler
/**
* A practical example of PII protection using the Sensitive type wrapper
*
* Encloses values in a closure and automatically masks them in
* JSON.stringify / console.log / template literals. Integrates with ArkType to auto-wrap at validation time.
*/
import { type } from "arktype";
// --- Sensitive Type ---
type Sensitive<T> = Readonly<{
unwrap: () => T;
toJSON: () => string;
toString: () => string;
}>;
const Sensitive = {
of: <T>(value: T): Sensitive<T> => ({
unwrap: () => value,
toJSON: () => "[REDACTED]",
toString: () => "[REDACTED]",
[Symbol.for("nodejs.util.inspect.custom")]: () => "[REDACTED]",
}),
} as const;
// --- ArkType Integration ---
const sensitiveString = type("string").pipe(Sensitive.of);
const PatientSchema = type({
id: "string.uuid",
name: sensitiveString,
email: sensitiveString,
diagnosis: sensitiveString,
role: "string",
});
type Patient = typeof PatientSchema.infer;
// --- Usage ---
const rawData = {
id: "550e8400-e29b-41d4-a716-446655440000",
name: "John Doe",
email: "john@example.com",
diagnosis: "Hypertension",
role: "outpatient",
};
const result = PatientSchema(rawData);
if (result instanceof type.errors) {
throw new Error(`Validation failed: ${result.summary}`);
}
const patient: Patient = result;
// Safe: PII is masked
// {"id":"550e8400-...","name":"[REDACTED]","email":"[REDACTED]","diagnosis":"[REDACTED]","role":"outpatient"}
console.log(JSON.stringify(patient));
// Access actual value only when explicitly needed
const actualEmail: string = patient.email.unwrap();
/**
* A practical example of PII protection using the Sensitive type wrapper
*
* Encloses values in a closure and automatically masks them in
* JSON.stringify / console.log / template literals. Integrates with Valibot to auto-wrap at parse time.
*/
import * as v from "valibot";
// --- Sensitive Type ---
type Sensitive<T> = Readonly<{
unwrap: () => T;
toJSON: () => string;
toString: () => string;
}>;
const Sensitive = {
of: <T>(value: T): Sensitive<T> => ({
unwrap: () => value,
toJSON: () => "[REDACTED]",
toString: () => "[REDACTED]",
[Symbol.for("nodejs.util.inspect.custom")]: () => "[REDACTED]",
}),
} as const;
// --- Valibot Integration ---
const sensitiveString = v.pipe(v.string(), v.transform(Sensitive.of));
const PatientSchema = v.object({
id: v.pipe(v.string(), v.uuid()),
name: sensitiveString,
email: sensitiveString,
diagnosis: sensitiveString,
role: v.string(),
});
type Patient = v.InferOutput<typeof PatientSchema>;
// --- Usage ---
const rawData = {
id: "550e8400-e29b-41d4-a716-446655440000",
name: "John Doe",
email: "john@example.com",
diagnosis: "Hypertension",
role: "outpatient",
};
const patient: Patient = v.parse(PatientSchema, rawData);
// Safe: PII is masked
// {"id":"550e8400-...","name":"[REDACTED]","email":"[REDACTED]","diagnosis":"[REDACTED]","role":"outpatient"}
console.log(JSON.stringify(patient));
// Access actual value only when explicitly needed
const actualEmail: string = patient.email.unwrap();
/**
* A practical example of PII protection using the Sensitive type wrapper
*
* Encloses values in a closure and automatically masks them in
* JSON.stringify / console.log / template literals. Integrates with Zod to auto-wrap at parse time.
*/
import { z } from "zod";
// --- Sensitive Type ---
type Sensitive<T> = Readonly<{
unwrap: () => T;
toJSON: () => string;
toString: () => string;
}>;
const Sensitive = {
of: <T>(value: T): Sensitive<T> => ({
unwrap: () => value,
toJSON: () => "[REDACTED]",
toString: () => "[REDACTED]",
[Symbol.for("nodejs.util.inspect.custom")]: () => "[REDACTED]",
}),
} as const;
// --- Zod Integration ---
const sensitiveString = z.string().transform(Sensitive.of);
const PatientSchema = z.object({
id: z.string().uuid(),
name: sensitiveString,
email: sensitiveString,
diagnosis: sensitiveString,
role: z.string(),
});
type Patient = z.infer<typeof PatientSchema>;
// --- Usage ---
const rawData = {
id: "550e8400-e29b-41d4-a716-446655440000",
name: "John Doe",
email: "john@example.com",
diagnosis: "Hypertension",
role: "outpatient",
};
const patient: Patient = PatientSchema.parse(rawData);
// Safe: PII is masked
// {"id":"550e8400-...","name":"[REDACTED]","email":"[REDACTED]","diagnosis":"[REDACTED]","role":"outpatient"}
console.log(JSON.stringify(patient));
// Access actual value only when explicitly needed
const actualEmail: string = patient.email.unwrap();
/**
* State transition model for taxi dispatch requests
*
* A practical example of state transitions using Discriminated Union + Companion Object + pure functions.
* Invalid transitions are detected as compile errors.
*/
// --- Branded Types (.brand) ---
import { type } from "arktype";
const PassengerIdSchema = type("string.uuid").brand("PassengerId");
type PassengerId = typeof PassengerIdSchema.infer;
const DriverIdSchema = type("string.uuid").brand("DriverId");
type DriverId = typeof DriverIdSchema.infer;
const RequestIdSchema = type("string.uuid").brand("RequestId");
type RequestId = typeof RequestIdSchema.infer;
// --- Branded Type Companion Objects ---
const PassengerId = {
schema: PassengerIdSchema,
parse: (raw: string) => {
const result = PassengerIdSchema(raw);
return result instanceof type.errors ? { success: false as const, issues: result } : { success: true as const, value: result };
},
} as const;
const DriverId = {
schema: DriverIdSchema,
parse: (raw: string) => {
const result = DriverIdSchema(raw);
return result instanceof type.errors ? { success: false as const, issues: result } : { success: true as const, value: result };
},
} as const;
const RequestId = {
schema: RequestIdSchema,
parse: (raw: string) => {
const result = RequestIdSchema(raw);
return result instanceof type.errors ? { success: false as const, issues: result } : { success: true as const, value: result };
},
} as const;
// --- State Types ---
type Waiting = Readonly<{
kind: "Waiting";
requestId: RequestId;
passengerId: PassengerId;
createdAt: Date;
}>;
type EnRoute = Readonly<{
kind: "EnRoute";
requestId: RequestId;
passengerId: PassengerId;
driverId: DriverId;
assignedAt: Date;
}>;
type InTrip = Readonly<{
kind: "InTrip";
requestId: RequestId;
passengerId: PassengerId;
driverId: DriverId;
startedAt: Date;
}>;
type Completed = Readonly<{
kind: "Completed";
requestId: RequestId;
passengerId: PassengerId;
driverId: DriverId;
startedAt: Date;
completedAt: Date;
}>;
type Cancelled = Readonly<{
kind: "Cancelled";
requestId: RequestId;
passengerId: PassengerId;
cancelledAt: Date;
reason: string;
}>;
// --- Union Type ---
type TaxiRequest = Waiting | EnRoute | InTrip | Completed | Cancelled;
type CancellableRequest = Waiting | EnRoute | InTrip;
// --- Companion Object ---
const assertNever = (x: never): never => {
throw new Error(`Unexpected value: ${JSON.stringify(x)}`);
};
const TaxiRequest = {
create: (requestId: RequestId, passengerId: PassengerId, now: Date): Waiting => ({
kind: "Waiting",
requestId,
passengerId,
createdAt: now,
}),
assignDriver: (waiting: Waiting, driverId: DriverId, now: Date): EnRoute => ({
kind: "EnRoute",
requestId: waiting.requestId,
passengerId: waiting.passengerId,
driverId,
assignedAt: now,
}),
startTrip: (enRoute: EnRoute, now: Date): InTrip => ({
kind: "InTrip",
requestId: enRoute.requestId,
passengerId: enRoute.passengerId,
driverId: enRoute.driverId,
startedAt: now,
}),
complete: (inTrip: InTrip, now: Date): Completed => ({
kind: "Completed",
requestId: inTrip.requestId,
passengerId: inTrip.passengerId,
driverId: inTrip.driverId,
startedAt: inTrip.startedAt,
completedAt: now,
}),
cancel: (request: CancellableRequest, reason: string, now: Date): Cancelled => ({
kind: "Cancelled",
requestId: request.requestId,
passengerId: request.passengerId,
cancelledAt: now,
reason,
}),
isCancellable: (request: TaxiRequest) =>
request.kind === "Waiting" ||
request.kind === "EnRoute" ||
request.kind === "InTrip",
isTerminal: (request: TaxiRequest) =>
request.kind === "Completed" || request.kind === "Cancelled",
describe: (request: TaxiRequest): string => {
switch (request.kind) {
case "Waiting":
return `Waiting (created ${request.createdAt.toISOString()})`;
case "EnRoute":
return `Driver ${request.driverId} en route`;
case "InTrip":
return `In trip since ${request.startedAt.toISOString()}`;
case "Completed":
return `Completed at ${request.completedAt.toISOString()}`;
case "Cancelled":
return `Cancelled: ${request.reason}`;
default:
return assertNever(request);
}
},
} as const;
/**
* State transition model for taxi dispatch requests
*
* A practical example of state transitions using Discriminated Union + Companion Object + pure functions.
* Invalid transitions are detected as compile errors.
*/
// --- Branded Types (v.brand) ---
import * as v from "valibot";
const PassengerIdSchema = v.pipe(v.string(), v.uuid(), v.brand("PassengerId"));
type PassengerId = v.InferOutput<typeof PassengerIdSchema>;
const DriverIdSchema = v.pipe(v.string(), v.uuid(), v.brand("DriverId"));
type DriverId = v.InferOutput<typeof DriverIdSchema>;
const RequestIdSchema = v.pipe(v.string(), v.uuid(), v.brand("RequestId"));
type RequestId = v.InferOutput<typeof RequestIdSchema>;
// --- Branded Type Companion Objects ---
const PassengerId = {
schema: PassengerIdSchema,
parse: (raw: string) => v.safeParse(PassengerIdSchema, raw),
} as const;
const DriverId = {
schema: DriverIdSchema,
parse: (raw: string) => v.safeParse(DriverIdSchema, raw),
} as const;
const RequestId = {
schema: RequestIdSchema,
parse: (raw: string) => v.safeParse(RequestIdSchema, raw),
} as const;
// --- State Types ---
type Waiting = Readonly<{
kind: "Waiting";
requestId: RequestId;
passengerId: PassengerId;
createdAt: Date;
}>;
type EnRoute = Readonly<{
kind: "EnRoute";
requestId: RequestId;
passengerId: PassengerId;
driverId: DriverId;
assignedAt: Date;
}>;
type InTrip = Readonly<{
kind: "InTrip";
requestId: RequestId;
passengerId: PassengerId;
driverId: DriverId;
startedAt: Date;
}>;
type Completed = Readonly<{
kind: "Completed";
requestId: RequestId;
passengerId: PassengerId;
driverId: DriverId;
startedAt: Date;
completedAt: Date;
}>;
type Cancelled = Readonly<{
kind: "Cancelled";
requestId: RequestId;
passengerId: PassengerId;
cancelledAt: Date;
reason: string;
}>;
// --- Union Type ---
type TaxiRequest = Waiting | EnRoute | InTrip | Completed | Cancelled;
type CancellableRequest = Waiting | EnRoute | InTrip;
// --- Companion Object ---
const assertNever = (x: never): never => {
throw new Error(`Unexpected value: ${JSON.stringify(x)}`);
};
const TaxiRequest = {
create: (requestId: RequestId, passengerId: PassengerId, now: Date): Waiting => ({
kind: "Waiting",
requestId,
passengerId,
createdAt: now,
}),
assignDriver: (waiting: Waiting, driverId: DriverId, now: Date): EnRoute => ({
kind: "EnRoute",
requestId: waiting.requestId,
passengerId: waiting.passengerId,
driverId,
assignedAt: now,
}),
startTrip: (enRoute: EnRoute, now: Date): InTrip => ({
kind: "InTrip",
requestId: enRoute.requestId,
passengerId: enRoute.passengerId,
driverId: enRoute.driverId,
startedAt: now,
}),
complete: (inTrip: InTrip, now: Date): Completed => ({
kind: "Completed",
requestId: inTrip.requestId,
passengerId: inTrip.passengerId,
driverId: inTrip.driverId,
startedAt: inTrip.startedAt,
completedAt: now,
}),
cancel: (request: CancellableRequest, reason: string, now: Date): Cancelled => ({
kind: "Cancelled",
requestId: request.requestId,
passengerId: request.passengerId,
cancelledAt: now,
reason,
}),
isCancellable: (request: TaxiRequest) =>
request.kind === "Waiting" ||
request.kind === "EnRoute" ||
request.kind === "InTrip",
isTerminal: (request: TaxiRequest) =>
request.kind === "Completed" || request.kind === "Cancelled",
describe: (request: TaxiRequest): string => {
switch (request.kind) {
case "Waiting":
return `Waiting (created ${request.createdAt.toISOString()})`;
case "EnRoute":
return `Driver ${request.driverId} en route`;
case "InTrip":
return `In trip since ${request.startedAt.toISOString()}`;
case "Completed":
return `Completed at ${request.completedAt.toISOString()}`;
case "Cancelled":
return `Cancelled: ${request.reason}`;
default:
return assertNever(request);
}
},
} as const;
/**
* State transition model for taxi dispatch requests
*
* A practical example of state transitions using Discriminated Union + Companion Object + pure functions.
* Invalid transitions are detected as compile errors.
*/
// --- Branded Types (z.brand) ---
import { z } from "zod";
export const PassengerIdBrand = Symbol();
const PassengerIdSchema = z.string().uuid().brand<typeof PassengerIdBrand>();
type PassengerId = z.infer<typeof PassengerIdSchema>;
export const DriverIdBrand = Symbol();
const DriverIdSchema = z.string().uuid().brand<typeof DriverIdBrand>();
type DriverId = z.infer<typeof DriverIdSchema>;
export const RequestIdBrand = Symbol();
const RequestIdSchema = z.string().uuid().brand<typeof RequestIdBrand>();
type RequestId = z.infer<typeof RequestIdSchema>;
// --- Branded Type Companion Objects ---
const PassengerId = {
schema: PassengerIdSchema,
parse: (raw: string) => PassengerIdSchema.safeParse(raw),
} as const;
const DriverId = {
schema: DriverIdSchema,
parse: (raw: string) => DriverIdSchema.safeParse(raw),
} as const;
const RequestId = {
schema: RequestIdSchema,
parse: (raw: string) => RequestIdSchema.safeParse(raw),
} as const;
// --- State Types ---
type Waiting = Readonly<{
kind: "Waiting";
requestId: RequestId;
passengerId: PassengerId;
createdAt: Date;
}>;
type EnRoute = Readonly<{
kind: "EnRoute";
requestId: RequestId;
passengerId: PassengerId;
driverId: DriverId;
assignedAt: Date;
}>;
type InTrip = Readonly<{
kind: "InTrip";
requestId: RequestId;
passengerId: PassengerId;
driverId: DriverId;
startedAt: Date;
}>;
type Completed = Readonly<{
kind: "Completed";
requestId: RequestId;
passengerId: PassengerId;
driverId: DriverId;
startedAt: Date;
completedAt: Date;
}>;
type Cancelled = Readonly<{
kind: "Cancelled";
requestId: RequestId;
passengerId: PassengerId;
cancelledAt: Date;
reason: string;
}>;
// --- Union Type ---
type TaxiRequest = Waiting | EnRoute | InTrip | Completed | Cancelled;
type CancellableRequest = Waiting | EnRoute | InTrip;
// --- Companion Object ---
const assertNever = (x: never): never => {
throw new Error(`Unexpected value: ${JSON.stringify(x)}`);
};
const TaxiRequest = {
create: (requestId: RequestId, passengerId: PassengerId, now: Date): Waiting => ({
kind: "Waiting",
requestId,
passengerId,
createdAt: now,
}),
assignDriver: (waiting: Waiting, driverId: DriverId, now: Date): EnRoute => ({
kind: "EnRoute",
requestId: waiting.requestId,
passengerId: waiting.passengerId,
driverId,
assignedAt: now,
}),
startTrip: (enRoute: EnRoute, now: Date): InTrip => ({
kind: "InTrip",
requestId: enRoute.requestId,
passengerId: enRoute.passengerId,
driverId: enRoute.driverId,
startedAt: now,
}),
complete: (inTrip: InTrip, now: Date): Completed => ({
kind: "Completed",
requestId: inTrip.requestId,
passengerId: inTrip.passengerId,
driverId: inTrip.driverId,
startedAt: inTrip.startedAt,
completedAt: now,
}),
cancel: (request: CancellableRequest, reason: string, now: Date): Cancelled => ({
kind: "Cancelled",
requestId: request.requestId,
passengerId: request.passengerId,
cancelledAt: now,
reason,
}),
isCancellable: (request: TaxiRequest) =>
request.kind === "Waiting" ||
request.kind === "EnRoute" ||
request.kind === "InTrip",
isTerminal: (request: TaxiRequest) =>
request.kind === "Completed" || request.kind === "Cancelled",
describe: (request: TaxiRequest): string => {
switch (request.kind) {
case "Waiting":
return `Waiting (created ${request.createdAt.toISOString()})`;
case "EnRoute":
return `Driver ${request.driverId} en route`;
case "InTrip":
return `In trip since ${request.startedAt.toISOString()}`;
case "Completed":
return `Completed at ${request.completedAt.toISOString()}`;
case "Cancelled":
return `Cancelled: ${request.reason}`;
default:
return assertNever(request);
}
},
} as const;
@praha/byethrow
Basic API
import { Result } from "@praha/byethrow";| Function/Type | Description |
|---|---|
Result.Result<T, E> | Result type (`Success<T> \ |
Result.ResultAsync<T, E> | Type alias for Promise<Result<T, E>> |
Result.succeed(value) | Creates a success value ({ type: "Success", value }) |
Result.fail(error) | Creates a failure value ({ type: "Failure", error }) |
Result.do() | Creates Success<{}>. Starting point for incrementally building an object with bind |
Result.bind(name, fn) | Adds the result of fn to the success value object under the name key (andThen + merge) |
Result.andThrough(fn) | Executes a side effect and returns the original value on success |
Result.orThrough(fn) | Executes a side effect on the error side and returns the original error on failure |
Main differences from neverthrow:
- Plain objects instead of classes (discriminant is the
typefield) - Composition via
Result.pipe+ curried functions instead of method chaining andThrough/orThroughallow side effects while preserving the original value
Composition with Pipe
Result.pipe(
result,
Result.map((value) => transform(value)), // Transform the success value
Result.mapError((error) => transformErr(error)), // Transform the error value
Result.andThen((value) => nextResult(value)), // Chain to the next Result from a success value (flatMap)
Result.andThrough((value) => sideEffect(value)), // Execute a side effect and return the original value on success
Result.orElse((error) => recover(error)), // Recover from an error
);
// Async: passing a function that returns Promise<Result> to andThen/andThrough
// automatically promotes the entire pipe to a Promise (ResultMaybeAsync)
Result.pipe(
result,
Result.andThen((value) => fetchSomething(value)), // ResultAsync is also fine
Result.andThrough((value) => saveToDb(value)), // Side effects also support async
);
// do + bind: incrementally build an object
Result.pipe(
Result.do(), // Start from Success<{}>
Result.bind("user", () => findUser(userId)), // { user: User }
Result.bind("order", ({ user }) => findOrder(user)), // { user: User, order: Order }
Result.andThrough(({ order }) => validate(order)), // Validation (value is preserved)
Result.map(({ user, order }) => buildResponse(user, order)),
);
// Branching uses type guards
if (Result.isSuccess(result)) {
console.log(result.value);
} else {
console.log(result.error);
}Code Example: State Transition Pipeline
Following Railway Oriented Programming principles, extract each step into an independent function, and let the use case simply compose them with Result.pipe.
For the design of RequestResolver / RequestStore and how domain events are persisted atomically with state, see state-modeling.md#domain-events.
import { Result } from "@praha/byethrow";
// --- Branded Types ---
declare const RequestIdBrand: unique symbol;
type RequestId = string & { readonly [RequestIdBrand]: never };
declare const DriverIdBrand: unique symbol;
type DriverId = string & { readonly [DriverIdBrand]: never };
declare const PassengerIdBrand: unique symbol;
type PassengerId = string & { readonly [PassengerIdBrand]: never };
// --- State Types ---
type Waiting = Readonly<{
kind: "Waiting";
requestId: RequestId;
passengerId: PassengerId;
}>;
type EnRoute = Readonly<{
kind: "EnRoute";
requestId: RequestId;
passengerId: PassengerId;
driverId: DriverId;
}>;
// --- Repository Types ---
type RequestResolver = Readonly<{
findById: (id: RequestId) => Result.ResultAsync<Waiting | undefined, RepositoryError>;
}>;
type RequestStore = Readonly<{
save: (state: EnRoute) => Result.ResultAsync<void, RepositoryError>;
}>;
// --- Error Types ---
type AssignDriverError =
| Readonly<{ kind: "RequestNotFound"; requestId: RequestId }>
| Readonly<{ kind: "DriverNotAvailable"; driverId: DriverId }>
| Readonly<{ kind: "RepositoryError"; cause: unknown }>;
type RepositoryError = Readonly<{ kind: "RepositoryError"; cause: unknown }>;
// --- Domain Functions ---
const findWaitingRequest =
(requestResolver: RequestResolver) =>
(requestId: RequestId): Result.ResultAsync<Waiting | undefined, AssignDriverError> =>
requestResolver.findById(requestId);
const ensureExists =
(requestId: RequestId) =>
(request: Waiting | undefined): Result.Result<Waiting, AssignDriverError> =>
request !== undefined
? Result.succeed(request)
: Result.fail({ kind: "RequestNotFound", requestId });
const ensureDriverAvailable =
(driverId: DriverId, isAvailable: boolean) =>
(): Result.Result<DriverId, AssignDriverError> =>
isAvailable
? Result.succeed(driverId)
: Result.fail({ kind: "DriverNotAvailable", driverId });
const transitionToEnRoute = (ctx: {
waiting: Waiting;
driverId: DriverId;
}): EnRoute => ({
kind: "EnRoute",
requestId: ctx.waiting.requestId,
passengerId: ctx.waiting.passengerId,
driverId: ctx.driverId,
});
// --- Use Case (full pipeline composition with do + bind) ---
const assignDriverUseCase =
(requestResolver: RequestResolver, requestStore: RequestStore) =>
(
requestId: RequestId,
driverId: DriverId,
isDriverAvailable: boolean,
): Result.ResultAsync<EnRoute, AssignDriverError> =>
Result.pipe(
Result.do(),
// 1. Fetch request → verify existence
Result.bind("waiting", () =>
Result.pipe(
findWaitingRequest(requestResolver)(requestId),
Result.andThen(ensureExists(requestId)),
),
),
// 2. Check driver availability
Result.bind("driverId", () =>
ensureDriverAvailable(driverId, isDriverAvailable)(),
),
// 3. State transition
Result.map(transitionToEnRoute),
// 4. Persist
Result.andThrough(requestStore.save),
);fp-ts
Basic API
import * as E from "fp-ts/Either";
import * as TE from "fp-ts/TaskEither";
import { pipe } from "fp-ts/function";| Function/Type | Description |
|---|---|
Either<E, A> | Synchronous Result type. Error is the first type argument (Left), success is the second (Right) |
TaskEither<E, A> | Asynchronous Result type (() => Promise<Either<E, A>>) |
E.right(value) | Creates a success value |
E.left(error) | Creates a failure value |
TE.Do | Creates TaskEither<never, {}>. Starting point for incrementally building an object with bind |
TE.bind(name, fn) | Adds the result of fn to the success value object under the name key |
TE.chainFirst(fn) | Executes a side effect and returns the original value on success |
TE.chainEitherK(fn) | Incorporates a function returning synchronous Either into a TaskEither chain |
Composition with Pipe
In fp-ts, functions are composed with pipe rather than method chaining.
pipe(
E.right(value),
E.map((a) => transform(a)), // Transform the success value
E.mapLeft((e) => transformErr(e)), // Transform the error value
E.chain((a) => nextEither(a)), // Chain to the next Either from a success value (flatMap)
E.chainFirst((a) => sideEffect(a)), // Execute a side effect and return the original value on success
E.fold(
(error) => handleErr(error),
(value) => handleOk(value),
),
);
// Do + bind: incrementally build an object
pipe(
TE.Do, // Start from TaskEither<never, {}>
TE.bind("user", () => findUser(userId)), // { user: User }
TE.bind("order", ({ user }) => findOrder(user)), // { user: User, order: Order }
TE.chainFirst(({ order }) => validate(order)), // Validation (value is preserved)
TE.map(({ user, order }) => buildResponse(user, order)),
);Code Example: State Transition Pipeline
Following Railway Oriented Programming principles, extract each step into an independent function, and let the use case simply compose them with pipe + Do/bind/chainFirst.
For the design of RequestResolver / RequestStore and how domain events are persisted atomically with state, see state-modeling.md#domain-events.
import * as E from "fp-ts/Either";
import * as TE from "fp-ts/TaskEither";
import { pipe } from "fp-ts/function";
// --- Branded Types ---
declare const RequestIdBrand: unique symbol;
type RequestId = string & { readonly [RequestIdBrand]: never };
declare const DriverIdBrand: unique symbol;
type DriverId = string & { readonly [DriverIdBrand]: never };
declare const PassengerIdBrand: unique symbol;
type PassengerId = string & { readonly [PassengerIdBrand]: never };
// --- State Types ---
type Waiting = Readonly<{
kind: "Waiting";
requestId: RequestId;
passengerId: PassengerId;
}>;
type EnRoute = Readonly<{
kind: "EnRoute";
requestId: RequestId;
passengerId: PassengerId;
driverId: DriverId;
}>;
// --- Repository Types ---
type RequestResolver = Readonly<{
findById: (id: RequestId) => TE.TaskEither<RepositoryError, Waiting | undefined>;
}>;
type RequestStore = Readonly<{
save: (state: EnRoute) => TE.TaskEither<RepositoryError, void>;
}>;
// --- Error Types ---
type AssignDriverError =
| Readonly<{ kind: "RequestNotFound"; requestId: RequestId }>
| Readonly<{ kind: "DriverNotAvailable"; driverId: DriverId }>
| Readonly<{ kind: "RepositoryError"; cause: unknown }>;
type RepositoryError = Readonly<{ kind: "RepositoryError"; cause: unknown }>;
// --- Domain Functions ---
const ensureExists =
(requestId: RequestId) =>
(request: Waiting | undefined): E.Either<AssignDriverError, Waiting> =>
request !== undefined
? E.right(request)
: E.left({ kind: "RequestNotFound", requestId });
const ensureDriverAvailable =
(driverId: DriverId, isAvailable: boolean) =>
(): E.Either<AssignDriverError, DriverId> =>
isAvailable
? E.right(driverId)
: E.left({ kind: "DriverNotAvailable", driverId });
const transitionToEnRoute = (ctx: {
waiting: Waiting;
driverId: DriverId;
}): EnRoute => ({
kind: "EnRoute",
requestId: ctx.waiting.requestId,
passengerId: ctx.waiting.passengerId,
driverId: ctx.driverId,
});
// --- Use Case (full pipeline composition with Do + bind) ---
const assignDriverUseCase =
(requestResolver: RequestResolver, requestStore: RequestStore) =>
(
requestId: RequestId,
driverId: DriverId,
isDriverAvailable: boolean,
): TE.TaskEither<AssignDriverError, EnRoute> =>
pipe(
TE.Do,
// 1. Fetch request → verify existence
TE.bind("waiting", () =>
pipe(
requestResolver.findById(requestId),
TE.chainEitherK(ensureExists(requestId)),
),
),
// 2. Check driver availability
TE.bind("driverId", () =>
TE.fromEither(ensureDriverAvailable(driverId, isDriverAvailable)()),
),
// 3. State transition
TE.map(transitionToEnRoute),
// 4. Persist
TE.chainFirst(requestStore.save),
);neverthrow
Basic API
import { ok, err, Result, ResultAsync } from "neverthrow";| Function/Type | Description |
|---|---|
Result<T, E> | Synchronous Result type |
ResultAsync<T, E> | Asynchronous Result type (wrapper around Promise<Result>) |
ok(value) | Creates a success value |
err(error) | Creates a failure value |
.andThrough(fn) | Executes a side effect and returns the original value on success |
Chaining Methods
result
.map((value) => transform(value)) // Transform the success value
.mapErr((error) => transformErr(error)) // Transform the error value
.andThen((value) => nextResult(value)) // Chain to the next Result from a success value (flatMap)
.andThrough((value) => sideEffect(value)) // Execute a side effect and return the original value on success
.orElse((error) => recover(error)) // Recover from an error
.match(
(value) => handleOk(value),
(error) => handleErr(error),
);Code Example: State Transition Pipeline
Following Railway Oriented Programming principles, extract each step into an independent function, and let the use case simply compose them with method chaining. Use andThrough to run side effects while preserving the original value.
For the design of RequestResolver / RequestStore and how domain events are persisted atomically with state, see state-modeling.md#domain-events.
import { ok, err, Result, ResultAsync } from "neverthrow";
// --- Branded Types ---
declare const RequestIdBrand: unique symbol;
type RequestId = string & { readonly [RequestIdBrand]: never };
declare const DriverIdBrand: unique symbol;
type DriverId = string & { readonly [DriverIdBrand]: never };
declare const PassengerIdBrand: unique symbol;
type PassengerId = string & { readonly [PassengerIdBrand]: never };
// --- State Types ---
type Waiting = Readonly<{
kind: "Waiting";
requestId: RequestId;
passengerId: PassengerId;
}>;
type EnRoute = Readonly<{
kind: "EnRoute";
requestId: RequestId;
passengerId: PassengerId;
driverId: DriverId;
}>;
// --- Repository Types ---
type RequestResolver = Readonly<{
findById: (id: RequestId) => ResultAsync<Waiting | undefined, RepositoryError>;
}>;
type RequestStore = Readonly<{
save: (state: EnRoute) => ResultAsync<void, RepositoryError>;
}>;
// --- Error Types ---
type AssignDriverError =
| Readonly<{ kind: "RequestNotFound"; requestId: RequestId }>
| Readonly<{ kind: "DriverNotAvailable"; driverId: DriverId }>
| Readonly<{ kind: "RepositoryError"; cause: unknown }>;
type RepositoryError = Readonly<{ kind: "RepositoryError"; cause: unknown }>;
// --- Domain Functions ---
const ensureExists =
(requestId: RequestId) =>
(request: Waiting | undefined): Result<Waiting, AssignDriverError> =>
request !== undefined
? ok(request)
: err({ kind: "RequestNotFound", requestId });
const ensureDriverAvailable =
(driverId: DriverId, isAvailable: boolean) =>
(waiting: Waiting): Result<Waiting, AssignDriverError> =>
isAvailable
? ok(waiting)
: err({ kind: "DriverNotAvailable", driverId });
const transitionToEnRoute =
(driverId: DriverId) =>
(waiting: Waiting): EnRoute => ({
kind: "EnRoute",
requestId: waiting.requestId,
passengerId: waiting.passengerId,
driverId,
});
// --- Use Case (pipeline composition with andThrough) ---
const assignDriverUseCase =
(requestResolver: RequestResolver, requestStore: RequestStore) =>
(
requestId: RequestId,
driverId: DriverId,
isDriverAvailable: boolean,
): ResultAsync<EnRoute, AssignDriverError> =>
requestResolver
.findById(requestId)
.andThen(ensureExists(requestId))
.andThen(ensureDriverAvailable(driverId, isDriverAvailable))
.map(transitionToEnRoute(driverId))
.andThrough(requestStore.save);option-t
Basic API
import { createOk, createErr, isOk, isErr, unwrapOk } from "option-t/plain_result";
import { mapForResult } from "option-t/plain_result/map";
import { andThenForResult } from "option-t/plain_result/and_then";
import { andThenAsyncForResult } from "option-t/plain_result/and_then_async";
import { mapErrForResult } from "option-t/plain_result/map_err";
import { orElseForResult } from "option-t/plain_result/or_else";Or using namespace import:
import { Result } from "option-t/plain_result/namespace";
// Result.createOk, Result.map, Result.andThen, etc.| Function/Type | Description |
|---|---|
Result<T, E> | Result type (`Ok<T> \ |
createOk(value) | Creates a success value ({ ok: true, val: T, err: null }) |
createErr(error) | Creates a failure value ({ ok: false, val: null, err: E }) |
Main differences from neverthrow:
- Plain objects instead of classes (discriminant is the
okfield) - Composition via standalone functions instead of method chaining
- Async operations use
*Asyncvariant functions (return value isPromise<Result<T, E>>)
Composition with Functions
import { mapForResult } from "option-t/plain_result/map";
import { mapErrForResult } from "option-t/plain_result/map_err";
import { andThenForResult } from "option-t/plain_result/and_then";
import { orElseForResult } from "option-t/plain_result/or_else";
const mapped = mapForResult(result, (value) => transform(value));
const mappedErr = mapErrForResult(result, (error) => transformErr(error));
const chained = andThenForResult(result, (value) => nextResult(value));
const recovered = orElseForResult(result, (error) => recover(error));
// Branching uses type guards or the ok field
if (isOk(result)) {
console.log(result.val);
} else {
console.log(result.err);
}Code Example: State Transition Pipeline
For the design of RequestResolver / RequestStore and how domain events are persisted atomically with state, see state-modeling.md#domain-events.
import { createOk, createErr, isOk, isErr, type Result } from "option-t/plain_result";
import { andThenForResult } from "option-t/plain_result/and_then";
import { andThenAsyncForResult } from "option-t/plain_result/and_then_async";
import { mapAsyncForResult } from "option-t/plain_result/map_async";
// --- Branded Types ---
declare const RequestIdBrand: unique symbol;
type RequestId = string & { readonly [RequestIdBrand]: never };
declare const DriverIdBrand: unique symbol;
type DriverId = string & { readonly [DriverIdBrand]: never };
declare const PassengerIdBrand: unique symbol;
type PassengerId = string & { readonly [PassengerIdBrand]: never };
// --- State Types ---
type Waiting = Readonly<{
kind: "Waiting";
requestId: RequestId;
passengerId: PassengerId;
}>;
type EnRoute = Readonly<{
kind: "EnRoute";
requestId: RequestId;
passengerId: PassengerId;
driverId: DriverId;
}>;
// --- Repository Types ---
type RequestResolver = Readonly<{
findById: (id: RequestId) => Promise<Result<Waiting | undefined, RepositoryError>>;
}>;
type RequestStore = Readonly<{
save: (state: EnRoute) => Promise<Result<void, RepositoryError>>;
}>;
// --- Error Types ---
type AssignDriverError =
| Readonly<{ kind: "RequestNotFound"; requestId: RequestId }>
| Readonly<{ kind: "DriverNotAvailable"; driverId: DriverId }>
| Readonly<{ kind: "RepositoryError"; cause: unknown }>;
type RepositoryError = Readonly<{ kind: "RepositoryError"; cause: unknown }>;
// --- Use Case ---
const assignDriverUseCase =
(requestResolver: RequestResolver, requestStore: RequestStore) =>
async (
requestId: RequestId,
driverId: DriverId,
isDriverAvailable: boolean,
): Promise<Result<EnRoute, AssignDriverError>> => {
const requestResult = await requestResolver.findById(requestId);
const waitingResult = andThenForResult(requestResult, (request) =>
request !== undefined
? createOk(request)
: createErr({ kind: "RequestNotFound" as const, requestId }),
);
if (isErr(waitingResult)) return waitingResult;
const waiting = waitingResult.val;
if (!isDriverAvailable) {
return createErr({ kind: "DriverNotAvailable" as const, driverId });
}
const enRoute: EnRoute = {
kind: "EnRoute",
requestId: waiting.requestId,
passengerId: waiting.passengerId,
driverId,
};
const saveResult = await requestStore.save(enRoute);
if (isErr(saveResult)) return saveResult;
return createOk(enRoute);
};State Modeling Detailed Guide
Designing State Transitions with Discriminated Unions
Design Steps
1. Enumerate the possible states of the domain entity 2. Identify the properties needed in each state 3. Define a separate type for each state (using kind as the discriminant) 4. Combine them into a Union type 5. Define valid transitions as pure functions 6. Group functions into a Companion Object
From State Diagram to Code
Waiting → EnRoute → InTrip → Completed
↓ ↓ ↓
Cancelled Cancelled CancelledThis state diagram translates into types and functions as follows.
// 1. Types for each state
type Waiting = Readonly<{
kind: "Waiting";
requestId: RequestId;
passengerId: PassengerId;
createdAt: Date;
}>;
type EnRoute = Readonly<{
kind: "EnRoute";
requestId: RequestId;
passengerId: PassengerId;
driverId: DriverId;
assignedAt: Date;
}>;
type InTrip = Readonly<{
kind: "InTrip";
requestId: RequestId;
passengerId: PassengerId;
driverId: DriverId;
startedAt: Date;
}>;
type Completed = Readonly<{
kind: "Completed";
requestId: RequestId;
passengerId: PassengerId;
driverId: DriverId;
startedAt: Date;
completedAt: Date;
}>;
type Cancelled = Readonly<{
kind: "Cancelled";
requestId: RequestId;
passengerId: PassengerId;
cancelledAt: Date;
reason: string;
}>;
// 2. Union type
type TaxiRequest = Waiting | EnRoute | InTrip | Completed | Cancelled;
// 3. Union of cancellable states (partial unions are also useful)
type CancellableRequest = Waiting | EnRoute | InTrip;
// 4. Transition functions
const TaxiRequest = {
assignDriver: (waiting: Waiting, driverId: DriverId, now: Date): EnRoute => ({
kind: "EnRoute",
requestId: waiting.requestId,
passengerId: waiting.passengerId,
driverId,
assignedAt: now,
}),
startTrip: (enRoute: EnRoute, now: Date): InTrip => ({
kind: "InTrip",
requestId: enRoute.requestId,
passengerId: enRoute.passengerId,
driverId: enRoute.driverId,
startedAt: now,
}),
complete: (inTrip: InTrip, now: Date): Completed => ({
kind: "Completed",
requestId: inTrip.requestId,
passengerId: inTrip.passengerId,
driverId: inTrip.driverId,
startedAt: inTrip.startedAt,
completedAt: now,
}),
cancel: (request: CancellableRequest, reason: string, now: Date): Cancelled => ({
kind: "Cancelled",
requestId: request.requestId,
passengerId: request.passengerId,
cancelledAt: now,
reason,
}),
isCancellable: (request: TaxiRequest) =>
request.kind === "Waiting" ||
request.kind === "EnRoute" ||
request.kind === "InTrip",
} as const;Notes
Handling shared properties: Even when properties like requestId or passengerId are common to all states, avoid inheriting from a base type via extends. Interface inheritance introduces declaration merging issues mentioned earlier. Accept the redundancy of explicitly defining properties in each state as a trade-off for type safety.
Generating timestamps: The example above accepts timestamps as arguments. This allows injecting arbitrary timestamps in tests, ensuring testability.
Domain Events
Record business-significant occurrences that accompany state transitions as domain events.
type DomainEvent<TName extends string, TPayload> = Readonly<{
eventId: string;
eventAt: Date;
eventName: TName;
payload: TPayload;
aggregateId: string;
aggregateName: string;
}>;
type DriverAssignedEvent = DomainEvent<
"DriverAssigned",
{ driverId: DriverId; passengerId: PassengerId }
>;
type TripCompletedEvent = DomainEvent<
"TripCompleted",
{ driverId: DriverId; duration: number }
>;Persist State and Events in the Same Transaction
The aggregate state and the events it emits must be persisted within the same transaction boundary. The naive approach of writing them in two separate steps suffers from the dual-write problem: the moment one succeeds and the other fails, the system is inconsistent.
// Bad — state and event are persisted in different transactions; a failure
// between them leaves the aggregate inconsistent.
saveRequest(entity).andThen(() => saveEvent(event));The standard implementation is the Outbox Pattern: write the state row and the outbox row atomically in the same DB transaction, and let a separate process relay outbox rows to the broker. Express this atomicity in the interface as well. Read-side concerns are split out as RequestResolver (ISP).
type RequestResolver = Readonly<{
findById: (id: RequestId) => ResultAsync<Waiting | undefined, RepositoryError>;
}>;
type RequestStore = Readonly<{
save: (
state: EnRoute,
events: readonly DriverAssignedEvent[],
) => ResultAsync<void, RepositoryError>;
}>;Closing save into a single method makes it structurally impossible for callers to produce a half-written aggregate where the state was updated but the event never fired.
Event Generation Responsibility
The use case layer generates events and hands them to RequestStore.save together with the state. Letting the repository generate events internally bloats its responsibilities by mixing persistence with business rules.
const buildDriverAssignedEvent =
(now: Date) =>
(enRoute: EnRoute): DriverAssignedEvent => ({
eventId: crypto.randomUUID(),
eventAt: now,
eventName: "DriverAssigned",
payload: { driverId: enRoute.driverId, passengerId: enRoute.passengerId },
aggregateId: enRoute.requestId,
aggregateName: "TaxiRequest",
});
const assignDriverUseCase =
(requestResolver: RequestResolver, requestStore: RequestStore) =>
(requestId: RequestId, driverId: DriverId, now: Date) =>
requestResolver
.findById(requestId)
.andThen(validateWaiting)
.map(transitionToEnRoute(driverId))
.andThrough((enRoute) =>
requestStore.save(enRoute, [buildDriverAssignedEvent(now)(enRoute)]),
);now is injected as a parameter; never call new Date() inside the use case so tests can pin time deterministically.
Test Data Guide
Type-Safe Test Fixtures with as const satisfies
Define dummy test data using as const satisfies Type. This preserves discriminant literal types and prevents widening.
const waitingRequest = {
kind: "Waiting",
passengerId: "passenger-1" as PassengerId,
} as const satisfies Waiting;
// waitingRequest.kind is the "Waiting" literal type (not string)Why not just as const?
as const alone preserves literal types but does not verify that the object matches the expected type. Adding satisfies Type ensures type compatibility at compile time while keeping the narrow literal type.
// ❌ No type checking — typos go unnoticed
const bad = {
kind: "Waitng", // typo not caught
passengerId: "passenger-1" as PassengerId,
} as const;
// ✅ Type-checked + literal types preserved
const good = {
kind: "Waiting",
passengerId: "passenger-1" as PassengerId,
} as const satisfies Waiting;ArkType
Basic API
import { type } from "arktype";| Function/Type | Description |
|---|---|
type({...}) | Object type definition |
type("string") | String type |
type("number") | Number type |
typeof Schema.infer | Extract TypeScript type from type definition |
schema(raw) | Returns validated data or type.errors |
schema.assert(raw) | Returns validated data or throws |
.brand("Name") | Adds a nominal brand to the output type |
.pipe(fn) | Transforms the validated value (morph) |
"string.uuid" | UUID format validation |
"string.email" | Email format validation |
Schema Definition
const CreateRequestInput = type({
passengerId: "string.uuid",
pickupLocation: {
lat: "number >= -90 & number <= 90",
lng: "number >= -180 & number <= 180",
},
});
type CreateRequestInput = typeof CreateRequestInput.infer;Branded Types
Define brands with .brand(). The validated output is automatically branded.
const UserIdSchema = type("string.uuid").brand("UserId");
type UserId = typeof UserIdSchema.infer;
const ProductIdSchema = type("string.uuid").brand("ProductId");
type ProductId = typeof ProductIdSchema.infer;
// Validated output is already branded — no `as` cast neededCompanion Object Pattern
const RequestIdSchema = type("string.uuid").brand("RequestId");
type RequestId = typeof RequestIdSchema.infer;
const RequestId = {
schema: RequestIdSchema,
parse: schemaResult(RequestIdSchema), // see boundary-defense.md for schemaResult
} as const;Sensitive Type Integration
Auto-wrap PII fields at validation time using .pipe().
const sensitiveString = type("string").pipe(Sensitive.of);
const PatientSchema = type({
id: "string.uuid",
name: sensitiveString,
email: sensitiveString,
diagnosis: sensitiveString,
role: "string", // not PII
});Validation Result Handling
ArkType returns validated data directly, or an ArkErrors instance on failure. Use instanceof type.errors to discriminate.
const result = CreateRequestInput(rawData);
if (result instanceof type.errors) {
// result is ArkErrors — an array of ArkError objects
console.error(result.summary);
} else {
// result is CreateRequestInput — validated data
console.log(result);
}Guidelines
- ArkType uses a call-based API (
schema(data)) instead ofsafeParse— checkinstanceof type.errorsfor failure - The schema factories in boundary-defense.md use the Standard Schema interface and work with ArkType without modification
- ArkType's type syntax mirrors TypeScript syntax (e.g.,
"string | number","string[]") for a minimal learning curve - ArkType is highly optimized for runtime performance and small bundle size, making it suitable for edge environments
.brand()eliminates the need forascasts on Branded Types
Valibot
Basic API
import * as v from "valibot";| Function/Type | Description |
|---|---|
v.object({...}) | Object schema |
v.string() | String schema |
v.number() | Number schema |
v.pipe(schema, ...actions) | Chain validations and transformations |
v.InferOutput<typeof Schema> | Extract TypeScript output type from schema |
v.safeParse(schema, raw) | Returns { success, output, issues } without throwing |
v.parse(schema, raw) | Returns parsed data or throws ValiError |
v.brand("Name") | Adds a nominal brand (used inside pipe) |
v.transform(fn) | Transforms the parsed value (used inside pipe) |
v.uuid() | UUID format validation (used inside pipe) |
Schema Definition
const CreateRequestInput = v.object({
passengerId: v.pipe(v.string(), v.uuid()),
pickupLocation: v.object({
lat: v.pipe(v.number(), v.minValue(-90), v.maxValue(90)),
lng: v.pipe(v.number(), v.minValue(-180), v.maxValue(180)),
}),
});
type CreateRequestInput = v.InferOutput<typeof CreateRequestInput>;Branded Types
Define brands with v.brand() inside v.pipe(). The schema output type becomes automatically branded.
const UserIdSchema = v.pipe(v.string(), v.uuid(), v.brand("UserId"));
type UserId = v.InferOutput<typeof UserIdSchema>;
const ProductIdSchema = v.pipe(v.string(), v.uuid(), v.brand("ProductId"));
type ProductId = v.InferOutput<typeof ProductIdSchema>;
// v.parse() output is already branded — no `as` cast neededCompanion Object Pattern
const RequestIdSchema = v.pipe(v.string(), v.uuid(), v.brand("RequestId"));
type RequestId = v.InferOutput<typeof RequestIdSchema>;
const RequestId = {
schema: RequestIdSchema,
parse: schemaResult(RequestIdSchema), // see boundary-defense.md for schemaResult
} as const;Sensitive Type Integration
Auto-wrap PII fields at parse time using v.transform() inside v.pipe().
const sensitiveString = v.pipe(v.string(), v.transform(Sensitive.of));
const PatientSchema = v.object({
id: v.pipe(v.string(), v.uuid()),
name: sensitiveString,
email: sensitiveString,
diagnosis: sensitiveString,
role: v.string(), // not PII
});Guidelines
- Use
v.safeParseoverv.parsefor Railway Oriented Programming integration (see boundary-defense.md for schema factory patterns) - The same schema factory works across all Standard Schema-compliant libraries — the schema factories in boundary-defense.md work with Valibot without modification
- Valibot is tree-shakeable and significantly smaller than Zod, making it ideal for edge environments (Cloudflare Workers, etc.)
v.brand()eliminates the need forascasts on Branded Types
Zod
Basic API
import { z } from "zod";| Function/Type | Description |
|---|---|
z.object({...}) | Object schema |
z.string() | String schema |
z.number() | Number schema |
z.infer<typeof Schema> | Extract TypeScript type from schema |
schema.safeParse(raw) | Returns { success, data, error } without throwing |
schema.parse(raw) | Returns parsed data or throws ZodError |
z.brand<typeof Brand>() | Adds a nominal brand to the output type (use unique symbol) |
.transform(fn) | Transforms the parsed value |
Schema Definition
const CreateRequestInput = z.object({
passengerId: z.string().uuid(),
pickupLocation: z.object({
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
}),
});
type CreateRequestInput = z.infer<typeof CreateRequestInput>;Branded Types
Define brands with z.brand(). The schema output type becomes automatically branded, eliminating the need for as casts.
export const UserIdBrand = Symbol();
const UserIdSchema = z.string().uuid().brand<typeof UserIdBrand>();
type UserId = z.infer<typeof UserIdSchema>;
export const ProductIdBrand = Symbol();
const ProductIdSchema = z.string().uuid().brand<typeof ProductIdBrand>();
type ProductId = z.infer<typeof ProductIdSchema>;
// safeParse().data is already branded — no `as` cast neededCompanion Object Pattern
export const RequestIdBrand = Symbol();
const RequestIdSchema = z.string().uuid().brand<typeof RequestIdBrand>();
type RequestId = z.infer<typeof RequestIdSchema>;
const RequestId = {
schema: RequestIdSchema,
parse: schemaResult(RequestIdSchema), // see boundary-defense.md for schemaResult
} as const;Sensitive Type Integration
Auto-wrap PII fields at parse time using .transform().
const sensitiveString = z.string().transform(Sensitive.of);
const PatientSchema = z.object({
id: z.string().uuid(),
name: sensitiveString,
email: sensitiveString,
diagnosis: sensitiveString,
role: z.string(), // not PII
});Guidelines
- Use
safeParseoverparsefor Railway Oriented Programming integration (see boundary-defense.md for schema factory patterns) - The same factory works across all Standard Schema-compliant libraries — the schema factories in boundary-defense.md work with Zod without modification
z.brand()eliminates the need forascasts on Branded Types- Use
unique symbol(viaSymbol()) instead of string literals for brand keys — string literals lack encapsulation and pollute autocomplete