Skip to content

Patterns

ghaerdi edited this page Oct 17, 2026 · 1 revision

Patterns

Five patterns for using rustify to make TypeScript code harder to get wrong. They build on each other: pattern 1 shapes the data, pattern 2 makes failure explicit, pattern 3 consumes the result exhaustively, pattern 4 keeps the data from drifting, and pattern 5 makes sure the data was valid in the first place.

The examples were run against the library before publishing; the outputs in the comments are the real ones, and the readonly violations in pattern 4 were confirmed with deno check.

1. Make invalid states unrepresentable

An interface with optional fields accepts every combination of present and absent properties, including the ones that make no sense:

// ❌ The type permits "data + error + loading" at the same time.
type ApiResponse = {
  data?: string;
  error?: string;
  isLoading?: boolean;
  statusCode?: number;
  retryAfter?: number;
};

Model each valid state as a variant of a discriminated union instead. Each variant carries only the fields that state actually has:

// ✅ Every combination the type accepts is a state the code can handle.
type ApiResponse =
  | { kind: "success"; data: string; statusCode: number }
  | { kind: "rate-limited"; retryAfter: number }
  | { kind: "error"; statusCode: number; error: string };

const describe = (res: ApiResponse): string =>
  match(res)
    .with({ kind: "success" }, (r) => `ok ${r.statusCode}: ${r.data}`)
    .with({ kind: "rate-limited" }, (r) => `retry in ${r.retryAfter}s`)
    .with({ kind: "error" }, (r) => `error ${r.statusCode}: ${r.error}`)
    .exhaustive();

describe({ kind: "success", data: "payload", statusCode: 200 });
// "ok 200: payload"

Use kind as the discriminant. type and status work too, but the exhaustiveness diagnostic names the variant only for kind, type and __tag: with kind it reports { kind: pending }, while with status it falls back to naming the container ({ isOk: true }).

Do not add field?: never to the variants that lack a field. The discriminant already makes the union exclusive; the extra property is noise that has to be maintained.

Reach for this when an interface has three or more optional fields, when some fields are only meaningful together, or when several booleans cannot all be true at once.

2. Errors as values

A function that can fail should say so in its return type. try/catch hides the failure path from the signature, and nullable returns hide it from the type system:

function parseAge(raw: string): Result<number, string> {
  const age = Number(raw);
  if (!Number.isInteger(age)) return Err(`not an integer: ${raw}`);
  if (age < 0) return Err(`negative age: ${age}`);
  return Ok(age);
}

const r = parseAge("41");
r.isOk(); // true
r.unwrap(); // 41

Wrapping code that throws

Result.from runs a thunk and captures whatever it throws. The optional second argument maps the thrown value; the default keeps Error instances and stringifies anything else.

const parseJson = (input: string): Result<unknown, Error> =>
  Result.from(() => JSON.parse(input));

parseJson('{"a":1}').unwrap(); // { a: 1 }
parseJson("{oops").isErr(); // true

// Custom error mapping
const readConfig = (path: string): Result<string, string> =>
  Result.from(
    () => Deno.readTextFileSync(path),
    (e) => `cannot read ${path}: ${e instanceof Error ? e.message : e}`,
  );

Result.fromAsync is the same for promises.

Nullable values

Option replaces T | null | undefined in the same way. Option.fromNullable takes a thunk, not a value: it calls it once, immediately, and wraps the result.

const users = new Map<string, string>([["u1", "ada"]]);

const findUser = (id: string): Option<string> =>
  Option.fromNullable(() => users.get(id));

findUser("u1").unwrapOr("anonymous"); // "ada"
findUser("nope").unwrapOr("anonymous"); // "anonymous"

Chaining

const parsePositive = (raw: string): Result<number, string> =>
  parseAge(raw).andThen((n) => (n === 0 ? Err("must be positive") : Ok(n)));

parsePositive("41").map((n) => n * 2).unwrap(); // 82
parsePositive("0").unwrapErr(); // "must be positive"

Do not use Result for programming errors that should never happen (broken invariants, impossible branches). Those belong in throw or an assertion — wrapping them in Result just makes callers handle a case that cannot occur.

3. Exhaustive matching

match() consumes a union without a switch and without a manual never check. .exhaustive() fails to compile while a variant is missing, naming the one you forgot:

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rect"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };

const area = (s: Shape): number =>
  match(s)
    .with({ kind: "circle" }, (v) => Math.PI * v.radius ** 2)
    .with({ kind: "rect" }, (v) => v.width * v.height)
    .with({ kind: "triangle" }, (v) => (v.base * v.height) / 2)
    .exhaustive();

area({ kind: "rect", width: 3, height: 4 }); // 12

Handlers receive the narrowed value. Shape patterns are partial: { kind: "circle" } matches any object carrying that field, and the handler still gets the whole object, so destructuring the other fields works.

Terminals

Terminal Behaviour Return type
.exhaustive() compile error while a variant is uncovered; throws at runtime if nothing matched the handlers' union
.otherwise(fn) default case, handler receives the whole input handlers' union, widened with fn's
.run() undefined when nothing matched T or undefined until the cases are exhaustive

.exhaustive() throwing at runtime is the escape hatch for data that entered the program without type checking — not something to design around.

Patterns and guards

const label = (v: unknown): string =>
  match(v)
    .with(0, () => "zero") // literal, compared with Object.is
    .with(P.string, (s) => `string of ${s.length}`)
    .with(P.array(P.number), (ns) => `${ns.length} numbers`)
    .with(P.instanceOf(Date), (d) => d.toISOString())
    .with({ role: "admin" }, (u) => `admin ${u.role}`) // partial object shape
    .with(P.union(P.nullish, P.boolean), () => "empty")
    .with(P.not(P.nullish), () => "something else")
    .otherwise(() => "unreachable");

label(0); // "zero"
label("abc"); // "string of 3"
label([1, 2, 3]); // "3 numbers"
label(null); // "empty"

P.when takes a type-guard predicate and narrows the handler's parameter to whatever the guard asserts.

One case per variant of a discriminated union

Passing a sub-pattern to Result.ok, Result.err or Option.some matches one variant of a discriminated union per case. Each case removes the variants it covers from the accounting, so .exhaustive() still names what is missing:

type AppError =
  | { kind: "not_found"; id: string }
  | { kind: "io"; code: number };

const explain = (r: Result<string, AppError>): string =>
  match(r)
    .with(Result.ok, (v) => `ok: ${v}`)
    .with(Result.err({ kind: "not_found" }), (e) => `missing ${e.id}`)
    .with(Result.err({ kind: "io" }), (e) => `io ${e.code}`)
    .exhaustive();

explain(Err({ kind: "io", code: 13 })); // "io 13"

The sub-pattern is required when you want one variant: Result.err alone matches every Err. A misspelled field ({ kynd: "io" }) never matches at runtime, and .exhaustive() keeps the variant uncovered — the mistake cannot silently widen into "matches everything".

matches() for one-off checks

matches({ kind: "circle", radius: 5 }, { kind: "circle" }); // true
matches("hello", P.string); // true
matches([1, "2"], P.array(P.number)); // false

Use matches() when you need a boolean, match() when you need to branch.

4. Immutable by default

Functions that mutate their arguments surprise callers. Return new values, and let the type system enforce it with Immutable<T>, which is deep: nested objects become readonly and Array/Map/Set become their readonly counterparts.

type Config = Immutable<{
  database: { host: string; port: number };
  features: string[];
}>;

// ❌ Both lines are compile errors: the tree is readonly.
// config.database.port = 5432;
// config.features.push("beta");

// ✅ Update by returning a new value.
const update = (cfg: Config, port: number): Config => ({
  ...cfg,
  database: { ...cfg.database, port },
});

Mutable<T> is the deep inverse, for the places where mutation is deliberate. Signal it in the name so a reader knows the object will change:

const sortInPlace = (items: Mutable<readonly { id: number }[]>): void => {
  items.sort((a, b) => a.id - b.id);
};

Local mutation is fine — a let accumulator inside one function has no external effect. The rule is about scope, not about the keyword.

5. Parse at the boundary

Untrusted data becomes typed data exactly once, at the edge of the program. Everything downstream receives a type that is already valid and never re-validates.

type Email = string & { readonly __brand: "Email" };

const parseEmail = (raw: unknown): Result<Email, string> => {
  if (typeof raw !== "string") return Err("email must be a string");
  if (!/^[^\s@]+@[^\s@]+$/.test(raw)) return Err(`invalid email: ${raw}`);
  return Ok(raw as Email);
};

// Everything inside has already been checked.
const sendWelcome = (email: Email): string => `welcome sent to ${email}`;

parseEmail("ada@example.com").map(sendWelcome).unwrapOr("rejected");
// "welcome sent to ada@example.com"

The boundary is: HTTP handlers, file and network reads, JSON.parse, environment variables, third-party SDK responses. Pick one function per boundary and let it return Result.

A schema library removes the hand-written checks and gives better messages. The block below needs zod installed:

// deno add npm:zod   /   npm install zod
const UserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  role: z.enum(["admin", "user", "guest"]),
});

type User = z.infer<typeof UserSchema>;

const parseUser = (raw: unknown): Result<User, string> => {
  const parsed = UserSchema.safeParse(raw);
  return parsed.success ? Ok(parsed.data) : Err(parsed.error.message);
};

Result.from composes with it: parse first, then hand the typed value to code that takes User and never checks again.

Combining them

type Payment = Immutable<
  | { kind: "card"; last4: string }
  | { kind: "transfer"; account: string }
>;

const parsePayment = (raw: unknown): Result<Payment, string> => {
  if (typeof raw !== "object" || raw === null) return Err("not an object");
  const v = raw as Record<string, unknown>;
  if (v.kind === "card" && typeof v.last4 === "string") {
    return Ok({ kind: "card", last4: v.last4 });
  }
  if (v.kind === "transfer" && typeof v.account === "string") {
    return Ok({ kind: "transfer", account: v.account });
  }
  return Err("unknown payment shape");
};

const display = (p: Payment): string =>
  match(p)
    .with({ kind: "card" }, (v) => `****${v.last4}`)
    .with({ kind: "transfer" }, (v) => `****${v.account.slice(-4)}`)
    .exhaustive();

parsePayment({ kind: "card", last4: "4242" }).map(display).unwrap();
// "****4242"

The union makes bad states unrepresentable, the parser produces it from untrusted input, Immutable keeps it from drifting, Result reports the failure, and match proves every variant is handled.

Code review checklist

Red flags:

  • booleans standing in for state (isLoading + isError + isSuccess)
  • a function that throws for an expected failure (validation, network, auth)
  • untrusted data used without a parse step
  • a function that mutates its argument without saying so in the name
  • any where a union or a Result would carry the same information
  • catch blocks that swallow the error or log and return a default
  • switch over a union with no exhaustiveness check

Green flags:

  • discriminated unions on kind
  • Result<T, E> for expected failures; throw only for invariants
  • parsing at every boundary, once
  • immutable returns by default, InPlace suffix when not
  • match(...).exhaustive() at the point where a union is consumed
  • import type for type-only imports

Decision flow

  1. Is the data external or untrusted? → parse it at the boundary, return Result (pattern 5).
  2. Can the operation fail for an expected reason? → return Result instead of throwing (pattern 2).
  3. Is there a state machine, a multi-step flow, or a set of flags that cannot all be true? → discriminated union on kind (pattern 1).
  4. Are you consuming a union? → match(...).exhaustive(), not switch (pattern 3).
  5. Might a function mutate something the caller owns? → return a new value, or name the mutation (pattern 4).

See Also

Clone this wiki locally