Repository navigation
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.
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.
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(); // 41Result.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.
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"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.
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 }); // 12Handlers 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.
| 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.
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.
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({ kind: "circle", radius: 5 }, { kind: "circle" }); // true
matches("hello", P.string); // true
matches([1, "2"], P.array(P.number)); // falseUse matches() when you need a boolean, match() when you need to branch.
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.
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.
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.
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
-
anywhere a union or aResultwould carry the same information -
catchblocks that swallow the error or log and return a default -
switchover a union with no exhaustiveness check
Green flags:
- discriminated unions on
kind -
Result<T, E>for expected failures;throwonly for invariants - parsing at every boundary, once
- immutable returns by default,
InPlacesuffix when not -
match(...).exhaustive()at the point where a union is consumed -
import typefor type-only imports
- Is the data external or untrusted? → parse it at the boundary, return
Result(pattern 5). - Can the operation fail for an expected reason? → return
Resultinstead of throwing (pattern 2). - 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). - Are you consuming a union? →
match(...).exhaustive(), notswitch(pattern 3). - Might a function mutate something the caller owns? → return a new value, or name the mutation (pattern 4).