Introduction
Branding a type with as is easy, but it trusts the caller to provide a valid value. Smart constructors combine branded types with runtime validation so that if a value passes the constructor, it is guaranteed to be both valid and correctly branded.
Key Concepts
- Smart Constructor: A function that validates input at runtime and returns a branded type on success or an error on failure.
- Opaque Type: A branded type whose creation is restricted to a single module, preventing arbitrary casting elsewhere.
- Validation Function: A runtime check (regex, range, business rule) applied before branding.
Real World Context
An Email branded type is useless if you can write "not-an-email" as Email. Smart constructors enforce that every Email value has actually passed a regex check, eliminating an entire category of invalid-data bugs at the boundary where user input enters your system.
Deep Dive
A smart constructor returns a Result-like type so callers must handle the failure case:
typescripttype Brand<B, T extends string> = B & { readonly __brand: T }; type Email = Brand<string, "Email">; function createEmail(input: string): Email { const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!regex.test(input)) { throw new Error(`Invalid email: ${input}`); } return input as Email; } const valid = createEmail("alice@example.com"); // Email // createEmail("bad"); // throws at runtime
For a safer API without exceptions, return a discriminated union:
typescripttype Result<T> = { ok: true; value: T } | { ok: false; error: string }; function parseEmail(input: string): Result<Email> { const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!regex.test(input)) { return { ok: false, error: `Invalid email: ${input}` }; } return { ok: true, value: input as Email }; }
To make the brand truly opaque, export only the type and the constructor — never export a raw as cast:
typescript// email.ts — the ONLY place that casts to Email export type { Email }; export { parseEmail }; // Consumers cannot write `as Email` because the brand tag is not exported
You can also use assertion functions for an ergonomic throwing API:
typescriptfunction assertEmail(input: string): asserts input is Email { if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input)) { throw new Error(`Invalid email: ${input}`); } }
Common Pitfalls
- Exposing the brand tag — If consumers can import the brand tag, they can bypass the constructor with
as Email. Keep the tag private to the module. - Skipping validation in tests — Even in tests, use the smart constructor. Using
as Emailin tests means your tests do not exercise the validation logic.
Best Practices
- One constructor per branded type — Centralize creation to make validation changes easy and auditable.
- Return Result instead of throwing — Exceptions are invisible in TypeScript signatures. Returning a union makes the failure path explicit.
Summary
- Smart constructors wrap branded type creation with runtime validation.
- Returning a Result union is safer than throwing exceptions.
- Keep the brand tag private to prevent bypass via raw casting.
- Assertion functions (
asserts input is T) offer a concise throwing alternative.
Code Examples
type Brand<B, T extends string> = B & { readonly __brand: T };
type PositiveInt = Brand<number, "PositiveInt">;
// Smart constructor with Result return
function toPositiveInt(n: number): PositiveInt {
if (!Number.isInteger(n) || n <= 0) {
throw new Error(`Expected positive integer, got ${n}`);
}
return n as PositiveInt;
}
const quantity = toPositiveInt(5); // PositiveInt
// toPositiveInt(-1); // throws Error
// toPositiveInt(3.5); // throws Error