Build Your Own Bedrock · Part 6

The create/createE/decoder triple

TypeFirst · AgileLabs Engineering · 8 min read

Part 5 ended on a cliffhanger: Opaque.ts is pure mechanism, no policy. Policy arrives as a module recipe that bedrock repeats for every domain value — Nat, Email, Password, UUID, Timestamp, all of them. Learn the recipe once and you can read (and extend) the entire Core/Data tree on sight. Five ingredients:

The recipe, in Nat.ts

// Core/Data/Number/Nat.ts
const key: unique symbol = Symbol()
/** Nat include 0 */
export type Nat = Opaque<number, typeof key>
export type ErrorNat = "NOT_AN_INT" | "NOT_A_NAT"

export function createNat(n: number): Maybe<Nat> {
  return toMaybe(createNatE(n))
}

export function createNatE(n: number): Result<ErrorNat, Nat> {
  return mapOk(_validate(n), jsonValueCreate(key))
}

function _validate(n: number): Result<ErrorNat, number> {
  return Number.isInteger(n) === false
    ? err("NOT_AN_INT")
    : n < 0
      ? err("NOT_A_NAT")
      : ok(n)
}

export const natDecoder: JD.Decoder<Nat> = JD.number.transform((n) => {
  return throwIfNull(createNat(n), `Invalid nat: ${n}`)
})

Follow the derivation chain. The private _validate owns the rules and returns Result on the bare primitive. createNatE lifts a pass into the opaque type with mapOk(…, jsonValueCreate(key)) — validation and wrapping composed in one line. createNat is toMaybe of the E-variant, for call sites that don't need the reason. And natDecoder reuses createNat inside .transform, where throwIfNull's throw is caught by the decoders library and surfaces as a decode failure — the escape hatch from Part 3 landing in its net. One rule set, three entry points, zero duplicated logic. (There's a fourth for stringly boundaries: natStringDecoder runs Number(n) on a string before the same gate — URL params, covered later in the series.)

Closed arithmetic

A wrapper you must unwrap for every operation is a chore, and chores get bypassed. So the module exports operations that stay inside the type:

// Core/Data/Number/Nat.ts
export function increment(n: Nat): Nat {
  return add(n, Nat1)
}

export function decrement(n: Nat): Maybe<Nat> {
  const num = n.unwrap() - 1
  return natDecoder.decode(num).value || null
}

export function add(n: Nat, i: Nat): Nat {
  return jsonValueCreate<number, typeof key>(key)(n.unwrap() + i.unwrap())
}

Read the return types as math. Naturals are closed under addition, so add and increment return Nat — inside the module they may call the raw factory directly, because two unwrapped Nats summed cannot be invalid. Naturals are not closed under subtraction: decrement(Nat0) would be −1, so decrement returns Maybe<Nat> and routes back through the gate. The signature is the theorem. Callers can't forget the edge case, because the edge case is the type.

Constants: pay the check at authoring time

// Core/Data/Number/Nat.ts
export const Nat0: Nat = jsonValueCreate<number, typeof key>(key)(0)
export const Nat1: Nat = jsonValueCreate<number, typeof key>(key)(1)
// … Nat2, Nat3, Nat5, Nat10, Nat30, Nat100, Nat300, Nat600, Nat900

Without these, writing a literal means createNat(30) — which returns Maybe<Nat> and forces you to handle a null that can never happen. For values a human verified at authoring time, the module bypasses its own gate and exports the wrapped constant directly. That's the module boundary's privilege working as intended: inside, you may use the raw factory, because you own the invariant. The sibling module Core/Data/Number/PositiveInt.ts repeats the identical recipe — constants PositiveInt1 through PositiveInt100, its own increment/add, its own error union — and Nat.ts bridges the two with fromPositiveInt, because every positive integer is a natural but their symbols differ. Conversions between opaque types are explicit functions, never assignments.

Fail fast or accumulate: Password does both

Core/App/User/Password.ts follows the same recipe, with a twist that earns its place in your bedrock:

// Core/App/User/Password.ts
export type ErrorPassword =
  | "INVALID_LENGTH"
  | "MISSING_NUMBER"
  | "MISSING_SYMBOL"
  | "CONTAINS_SPACE"

function _validate(s: string): Result<ErrorPassword, string> {
  if (s.length < 8) return err("INVALID_LENGTH")
  if (numberRegex.test(s) === false) return err("MISSING_NUMBER")
  if (symbolRegex.test(s) === false) return err("MISSING_SYMBOL")
  if (noSpaceRegex.test(s) === false) return err("CONTAINS_SPACE")
  return ok(s)
}

export function passwordErrors(s: string): Array<ErrorPassword> {
  const errors: Array<ErrorPassword> = []
  if (s.length < 8) {
    errors.push("INVALID_LENGTH")
  }
  if (numberRegex.test(s) === false) {
    errors.push("MISSING_NUMBER")
  }
  // … MISSING_SYMBOL, CONTAINS_SPACE follow the same shape
  return errors
}

_validate fails fast — a constructor needs one reason to refuse. But a signup form needs every unmet rule at once, so the user can fix all four before resubmitting instead of playing whack-a-mole. passwordErrors runs the same rules in accumulating mode and returns the full list. Same policy, two shapes, one module — the form UI calls passwordErrors on every keystroke while createPasswordE guards the actual construction. The module also ships passwordErrorString, an exhaustive switch from each error to display copy. Add a fifth rule to the union and the compiler walks you to every screen that must mention it. An error union isn't just plumbing; it's a UI asset.

That's the whole recipe. Every future part of this series — decoders at the walls, API contracts, database rows — consumes types built exactly this way. When you build your own bedrock, budget an hour for your first module and five minutes for each one after.

In your language

"Smart constructor" is an old Haskell term: export the type, hide the data constructor, expose mkNat :: Int -> Either Error Nat. Kotlin expresses it as a class with a private constructor and a companion-object of() returning a sealed result; Swift as a struct with private init plus a throwing or Result-returning static factory. In Rust the newtype's field stays private and Nat::new(i64) -> Result<Nat, NatError> is the only door. Go convention: NewNat(int) (Nat, error) over a struct with an unexported field. The triple — strict constructor, lenient constructor, boundary decoder — translates one-to-one once the constructor is private.

← Part 5: Opaque: values you can't forge · All posts · Part 7: Parse everything that crosses the wall →