Build Your Own Bedrock · Part 5

Opaque: values you can't forge

TypeFirst · AgileLabs Engineering · 7 min read

This site has covered opaque types twice from the user's seat — what they buy you and how to use them. This time you're in the author's seat, because you're building the file yourself. Every line of Core/Data/Opaque.ts is a decision, and there are only 47 of them. Here is the type in full:

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

Four decisions, one per feature: the symbol key, the unwrap method, the toJSON method, and that easy-to-miss third parameter. Take them in order.

Decision 1: a symbol, not a brand

The popular lightweight alternative is branding: type Email = string & { __brand: "Email" }. Compare what each costs an attacker. To forge a brand, you write "anything" as Email — one keyword. To forge a bedrock opaque value, you'd have to write an object literal containing a property whose key is a unique symbol declared as a non-exported const in someone else's module. You can't name it, so you can't build it. The wall isn't convention; it's scoping.

The runtime story differs just as much. A branded string is the raw string — drop a branded Password into a template literal or a log line and the secret prints. The brand is a compile-time fiction. Bedrock's opaque value is a real object whose payload hides behind a symbol key: interpolate it and you get [object Object]. The type doesn't just claim the value is protected; the value actually is. That's why the file's doc comment warns about the one sharp edge: the symbol must be unique per type. Reuse one key for two wrappers over string and TypeScript sees identical shapes — the comment's own example is Text256 == Email. One module, one const key: unique symbol = Symbol().

Decision 2: unwrap() lives on the value

Here's the subtle design problem: if the payload is only reachable through a symbol nobody outside can name, then nobody outside can read it either. Each module would need its own exported getter — emailToString, natToNumber — and exits from the type system would have twenty different names. Baking readonly unwrap: () => Unwrapped into the type solves both at once: reading is always possible, and it has exactly one spelling. When you audit where raw values leak into SQL strings or HTTP bodies, you grep for a single token: .unwrap(). Same philosophy as throwIfNull in Part 3 — escape is allowed, but it must be loud and uniform.

Decision 3: toJSON(), because symbols vanish

There's a trap waiting in JSON.stringify: it silently skips symbol-keyed properties. Stringify a symbol-keyed wrapper without help and you get {} — every opaque field in an API payload would serialize to nothing. The fix is the standard toJSON protocol: when present, stringify calls it and serializes the return value instead. So the type requires a toJSON, and an Email inside a response body serializes as its plain string with no marshalling layer. Later in the series, entire API contracts push opaque-typed records straight through JSON.stringify; this one method is what makes that a non-event.

Decision 4: the Unwrapped parameter

Opaque<T, K, Unwrapped = T> — why would the thing you store differ from the thing you hand back? Because sometimes the stored value is private machinery, and unwrap() should expose only its public face. Two real uses in the repo:

// Core/Data/Form/Field.ts
export type Field<E, V, T> =
  Opaque<FieldInternal<E, V, T>, typeof fieldKey, V>

// Core/Data/Security/JsonWebToken.ts
export type JsonWebToken<T> = Opaque<State<T>, typeof key, T>

A form Field stores a record of raw value, parser function and memoized parse result — but unwrap() yields only V, the raw input. A JsonWebToken stores the raw token, header, payload and signature as one internal record, but unwraps to just the typed payload T. The default Unwrapped = T keeps the simple cases (Nat, Email, UUID) at two type arguments, while the parameter quietly upgrades Opaque from "validated primitive" to "module-private data structure". You get abstraction and forgery-proofing from the same 6-line type.

The one factory

// Core/Data/Opaque.ts
export function jsonValueCreate<T extends JSONValue, K extends symbol>(
  key: K,
): (v: T) => Opaque<T, K> {
  return (value: T) => {
    return {
      [key]: value,
      unwrap: function () {
        return this[key]
      },
      toJSON: function () {
        return this[key]
      },
    }
  }
}

Three details reward a close read. It's curried on key, so a module applies it once and reuses the resulting (v: T) => Opaque<T, K> in constructors, constants and arithmetic. It constrains T extends JSONValue — the shortcut is only offered where "serialize as yourself" is coherent, which is why Field and friends build their values by hand instead. And it returns no Unwrapped variant at all: the convenience path is deliberately the boring path. What the file leaves out matters too — no validation, no error types, no registry of blessed wrappers. Opaque.ts is pure mechanism. Policy — what counts as a valid Nat or Password — belongs to each module, and that recipe is Part 6.

In your language

This is the one bedrock idea most languages support better than TypeScript, because module-private constructors are built in. Rust: a newtype pub struct Email(String) whose field stays private to the crate. Go: an unexported struct field makes the package boundary the wall. OCaml hides constructors behind a module signature; Haskell exports the type but not its data constructor; Elm does the same with exposing (Email) minus the variant. Python's NewType is closer to branding — erased at runtime — so pair it with a @dataclass(frozen=True) and an underscore-private field if you want the hiding to be real.

Coming to this fresh? The user's-seat versions are Opaque types: the TypeScript feature you're not using and the beginner-track Learn TypeFirst, Part 5 — read either first if this post moved too fast.

← Part 4: Result: errors are values · All posts · Part 6: The create/createE/decoder triple →