Learn TypeFirst · Part 5

Opaque types

TypeFirst · AgileLabs Engineering · 7 min read

The capstone. In Part 2 we noticed that type Email = string has infinite cardinality while real emails are a sliver of it. In Part 4 we learned to check data at the boundary. One problem remains: after you validate a string, it's still just a string. Nothing stops a colleague — or you, next month — from passing an unvalidated one to a function that assumed otherwise. The validation happened, but the type forgot.

An opaque type is how the type remembers.

The mechanism

An opaque type is a type whose values can only be created inside its own module. ts-bedrock builds them with a generic and one trick — a unique symbol key that nobody outside the module can name:

// Core/Data/Opaque.ts
export type Opaque<T, K extends symbol> = {
  [key in K]: T
} & {
  readonly unwrap: () => T
  readonly toJSON: () => JSONValue
}

// Your module — the symbol is private to it
const key: unique symbol = Symbol()
export type Email = Opaque<string, typeof key>

Because the only way to build a value containing that symbol is code in this module, the module's exports completely control how an Email comes into existence. Outside code can't make one, can't fake one, can't edit one. And because each opaque type has its own symbol, Email and Text100 are different types even though both wrap string — they can never be mixed up in a function call.

The smart constructor

The module exports exactly one front door, and the validation lives behind it:

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")
}

Now the whole series clicks together. The constructor returns a generic Result — failure is a value. A decoder (emailDecoder) wraps the same constructor so data entering from the outside world comes out as Email, not string. And every function downstream can demand the proof in its signature:

function login(email: Email, password: Password): …

login("alice@example.com", …)
// ✗ Type 'string' is not assignable to type 'Email'

This is "correct by construction": if you're holding an Email, a real validation ran, exactly once, at the boundary. No defensive re-checking, ever again. When the primitive must leave the type system — into a SQL query or an HTTP body — you call .unwrap(), which makes every exit point greppable.

Exercise: build Email yourself

The course exercise is exactly this: implement Email from scratch in Learn/Exercise/5-Opaque.ts — the opaque type, the smart constructor returning Result, a Maybe-flavored twin, and a decoder. Then test your walls: try to construct an Email directly in another file. If you can, your symbol leaked; if you can't, you've built your first correct-by-construction type.

Where you are now

That's the entire TypeFirst toolkit. The graduation project is the real codebase: ts-bedrock is these five ideas applied to a full production app — opaque domain types, decoders on every edge, Result through every handler, and one contract from database to DOM. You'll recognize every file.

Want the deeper dive? The engineering-track companion post — Opaque types: the TypeScript feature you're not using — covers branded-type comparisons, parse-don't-validate, and production ergonomics.

← All posts