Types
Opaque types: the TypeScript feature you're not using
Every TypeScript codebase has a type like this:
type Email = string It reads like a guarantee. It is a comment. Any string is assignable to it — "hello", the empty string, a user's password pasted into the wrong field. And because the alias is transparent, the reverse is also true: every function that takes an Email happily accepts any string, validated or not. The result is the most common defensive pattern in industry code: validation logic re-run in five places, because no function can trust that its caller already did it.
Making the type opaque
An opaque type is a type whose values cannot be constructed or modified outside its own module. If the only way to get an Email is through a function that validates, then holding an Email is proof of validation. Here is the entire mechanism, from ts-bedrock:
// Core/Data/Opaque.ts
export type Opaque<T, K extends symbol, Unwrapped = T> = {
[key in K]: T
} & {
readonly unwrap: () => Unwrapped
readonly toJSON: () => JSONValue
} The trick is the K extends symbol parameter. Each opaque type is keyed by a unique symbol that lives privately in its module. Since nobody outside the module can name that symbol, nobody outside the module can construct an object literal that satisfies the type. TypeScript's structural typing — usually the thing that makes "branding" leaky — is exactly what enforces it.
// Core/Data/User/Email.ts
const key: unique symbol = Symbol()
export type Email = Opaque<string, typeof key>
export function createEmailE(value: string): Result<ErrorEmail, Email> {
const cleaned = value.trim().toLowerCase()
return isValidEmail(cleaned)
? ok(jsonValueCreate<string, typeof key>(key)(cleaned))
: err("INVALID_EMAIL")
} And now the lie becomes a compile error:
const a: Email = "alice@example.com"
// ✗ Type 'string' is not assignable to type 'Email'
const b = createEmailE("alice@example.com")
// ✓ Result<ErrorEmail, Email> — you must handle the failure case Why a unique symbol per type matters
A popular lighter-weight version of this is the "branded string": type Email = string & { __brand: "email" }. It works — until two brands accidentally share a shape, or someone writes as Email to make a test fixture compile. The symbol version closes both holes. Because each module's key is a distinct unique symbol, two opaque types are never mutually assignable even if they wrap the same primitive:
declare const email: Email // Opaque<string, EmailKey>
declare const name: Text100 // Opaque<string, Text100Key>
sendTo(name)
// ✗ Type 'Text100' is not assignable to type 'Email' And in ts-bedrock, as is a lint error with zero tolerated warnings — so there is no back door. (More on that in We banned any, as, is and !.)
Parse, don't validate
The deeper idea — Alexis King's famous phrase — is that validation should produce evidence. A function that returns boolean throws the evidence away:
// Validation: evidence is lost, must re-check everywhere
function isValidEmail(s: string): boolean
// Parsing: evidence is carried by the type itself
function createEmailE(s: string): Result<ErrorEmail, Email> Downstream code stops being defensive. A function with signature function login(email: Email, password: Password) does not check its inputs — it cannot receive bad ones. Validation happens exactly once, at the boundary where data enters the system, and the type carries the proof everywhere else.
The ergonomics
Three things make this pleasant to live with in practice:
-
unwrap()at the exit boundary. When the primitive finally needs to leave the type system — into a SQL query, an HTTP body — you callemail.unwrap(). The call is greppable: you can audit every place a raw value escapes. -
toJSON()for free serialization.JSON.stringifycalls it automatically, so opaque values serialize as their plain inner value with no ceremony. - Smart constructors in two flavors. ts-bedrock pairs
createEmailE(returnsResult, for when you need the error) withcreateEmail(returnsMaybe, for when you don't). Same validation, one source of truth.
Once the pattern exists, everything sensitive gets the treatment: Password with its strength rules, UserID so it can never be confused with any other UUID, Timestamp, bounded text like Text100 for database columns. Each one deletes a category of bug at the cost of one small module.