Types

Opaque types: the TypeScript feature you're not using

TypeFirst · AgileLabs Engineering · 9 min read

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:

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.

See the full pattern. Read Core/Data/Opaque.ts and its uses across ts-bedrock, or work through the opaque types lesson in the Learn series.

← All posts