TS Patterns in Practice
satisfies, branded types, const params, discriminated unions.
You know the syntax. Now let's talk about how to actually use it. This lesson is a tour of patterns that separate the codebase that fights TypeScript from the codebase where the compiler is doing your work for you. Branded types, the satisfies operator, Resultover throws, type predicates. None of these are language features you'd find in a syntax tour. All of them change how a real codebase feels.
satisfies: validate without widening
TypeScript 4.9 added a small operator that solved a recurring pain. You want to write a value that conforms to a type, but you also want the compiler to remember the specific shape, not widen it to the type itself.
type Palette = Record<string, string | [number, number, number]>;
// Without satisfies: type is Palette, you lose the specific shape
const colors1: Palette = {
red: "#ff0000",
green: [0, 255, 0],
};
colors1.red.toUpperCase();
// ^ Error: Property 'toUpperCase' does not exist on type 'string | [number, number, number]'
// With satisfies: type stays specific, but it's still checked against Palette
const colors2 = {
red: "#ff0000",
green: [0, 255, 0],
} satisfies Palette;
colors2.red.toUpperCase(); // OK, TS knows red is a string
colors2.green[0].toFixed(2); // OK, TS knows green is a tupleThe mental model: : Type is a contract that widens the value to the contract. satisfies Typeis a check that does NOT widen. The value keeps its narrowest inferred shape, but the compiler still rejects it if it doesn't fit.
Branded (nominal) types: stop the ID mixup
TypeScript is structural. A string is a string is a string. The compiler will happily let you pass a UserId where an OrderId is expected. Branding is a trick that uses an unreachable, runtime-invisible property to make two structurally identical types nominally distinct.
// Define brands using an intersection with a phantom property
declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
// Cast a raw string to a branded type with a helper
function asUserId(s: string): UserId { return s as UserId; }
function asOrderId(s: string): OrderId { return s as OrderId; }
function getOrder(id: OrderId) { /* ... */ }
const u = asUserId("u_1");
const o = asOrderId("o_42");
getOrder(o); // OK
// getOrder(u); // Error: UserId is not assignable to OrderId
// getOrder("o_42"); // Error: string is not assignable to OrderIdAt runtime, a UserId is still just a string. The [brand]property doesn't exist. The compiler is enforcing a distinction that won't survive into shipped JavaScript. That's exactly what you want for IDs, opaque tokens, units (Brand<number, "Meters">), validated values, and so on.
const type parameters
TS 5.0 added const as a modifier on generic type parameters. It tells inference to preserve the narrowest possible literal type, the same way a const assertion does at a call site, but baked into the function.
// Without const: T widens to string[]
function names<T extends readonly string[]>(items: T): T {
return items;
}
const n1 = names(["ada", "grace"]);
// n1 is string[], so the specific values are lost
// With const: T preserves the literal tuple
function namesConst<const T extends readonly string[]>(items: T): T {
return items;
}
const n2 = namesConst(["ada", "grace"]);
// n2 is readonly ["ada", "grace"]
// You no longer need the call-site 'as const' for this to workResult<T, E>: returning errors instead of throwing
Thrown errors are untyped in TS by design. The compiler has no idea what a function might throw. A common pattern in serious codebases: wrap fallible operations in a discriminated union that forces the caller to handle the failure path.
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function parseInteger(s: string): Result<number, "NotANumber"> {
const n = parseInt(s, 10);
if (Number.isNaN(n)) return { ok: false, error: "NotANumber" };
return { ok: true, value: n };
}
const r = parseInteger("42");
if (r.ok) {
console.log(r.value + 1); // TS knows r.value is number here
} else {
console.log("Failed:", r.error); // TS knows r.error is "NotANumber" here
}try/catch required at the call site. Third, the error type can be a literal or a union, so switching on it gives you exhaustive coverage.Type predicates: x is T
A user-defined type guard. A normal function returning boolean tells the compiler nothing about x. A function returning x is User narrows x on the true branch.
type User = { id: string; name: string };
function isUser(x: unknown): x is User {
return (
typeof x === "object" &&
x !== null &&
"id" in x &&
"name" in x &&
typeof (x as User).id === "string" &&
typeof (x as User).name === "string"
);
}
const data: unknown = JSON.parse('{"id":"u_1","name":"Ada"}');
if (isUser(data)) {
console.log(data.name); // TS knows data is User here
}function isUser(x: unknown): x is User { return true; } typechecks. The compiler will narrow to Useron the true branch and you'll crash at runtime. Test your predicates, or use a validator like Zod that synthesizes a real predicate from a schema.Assertion functions: asserts x is T
A close cousin of type predicates. Instead of returning a boolean, an assertion function throws on the bad case. After it returns, the compiler narrows the asserted variable for the rest of the scope.
function assertIsString(x: unknown): asserts x is string {
if (typeof x !== "string") throw new Error("Expected string, got " + typeof x);
}
function shout(input: unknown) {
assertIsString(input);
// From here on, input is narrowed to string
return input.toUpperCase();
}
// Built-in style, assert non-null
function assertDefined<T>(x: T): asserts x is NonNullable<T> {
if (x === null || x === undefined) throw new Error("Unexpected nullish value");
}
const maybe: string | null = Math.random() > 0.5 ? "hi" : null;
assertDefined(maybe);
maybe.toUpperCase(); // OK, maybe is now stringPutting it together: a typed mini-API
Here's a small surface that combines branded IDs, Result, satisfies, and a type predicate. This is what a real production module looks like.
// Branded ID
declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };
type UserId = Brand<string, "UserId">;
function isUserId(s: unknown): s is UserId {
return typeof s === "string" && /^u_/.test(s);
}
// Result type
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
type FetchError = "NotFound" | "Network" | "Forbidden";
type User = { id: UserId; name: string; email: string };
async function getUser(id: UserId): Promise<Result<User, FetchError>> {
try {
const res = await fetch(`/api/users/${id}`);
if (res.status === 404) return { ok: false, error: "NotFound" };
if (res.status === 403) return { ok: false, error: "Forbidden" };
if (!res.ok) return { ok: false, error: "Network" };
return { ok: true, value: await res.json() };
} catch {
return { ok: false, error: "Network" };
}
}
// satisfies preserves the precise message map shape
const errorMessages = {
NotFound: "User not found",
Network: "A network error occurred",
Forbidden: "You do not have permission",
} satisfies Record<FetchError, string>;
async function loadProfile(rawId: string) {
if (!isUserId(rawId)) throw new Error("invalid id format");
const result = await getUser(rawId);
if (!result.ok) {
return { error: errorMessages[result.error] };
}
return { user: result.value };
}What does `satisfies` do that `:` does not?
Recap
satisfiesvalidates a value against a type without widening. Use it for config objects and lookup tables.- Branded types make structurally identical primitives nominally distinct. Use them for IDs and units.
consttype parameters preserve narrow literals across function boundaries.Result<T, E>moves failures into the type system and forces callers to handle them.- Type predicates (
x is T) and assertion functions (asserts x is T) let you teach the compiler what a runtime check has proved. The compiler trusts you, so check carefully.