Real Type Safety in TypeScript: 6 Practical Patterns
Many projects have TypeScript but no type safety. The tsconfig is there, the files are .ts, tsc is green in CI — yet every third spot in the code has an any, API responses are "vouched for" with as, and production still throws undefined is not a function. I call that "writing TypeScript". "Using TypeScript" is something else: turning the compiler into a teammate that hunts bugs for you. And remember: every time you write any, you are borrowing time — you will pay the interest later, in production bugs and scary refactorings.
Below are 6 patterns that have proven themselves in our teams. Each follows the same format: the problem, the solution code, and when to use it.
1. Discriminated unions: make impossible states impossible
Problem. Modeling request state with separate fields is a common mistake:
interface State {
isLoading: boolean;
data?: User[];
error?: string;
}This type allows isLoading: true and error: "..." at the same time. As long as impossible states "live" in the type, the code has to defend against them with if everywhere — and one day somebody forgets a guard.
Solution. Split the states with a shared status field:
type State =
| { status: "loading" }
| { status: "success"; data: User[] }
| { status: "error"; message: string };
function render(state: State) {
switch (state.status) {
case "loading":
return spinner();
case "success":
return list(state.data); // data exists only in this branch
case "error":
return alertBox(state.message);
default: {
// if a new status is added, this line fails at compile time
const _exhaustive: never = state;
return _exhaustive;
}
}
}The exhaustiveness check via never is the cheapest insurance there is. If someone adds status: "retrying", the compiler finds every forgotten switch for you — imagine hunting them down by hand.
When. Any state with a discriminating field like status, kind, or type: request lifecycles (loading/success/error), payment stages, WebSocket message types, wizard steps.
2. Branded types: a string is not always just a string
Problem. UserId is a string, and so is OrderId. Swap two function arguments and the compiler stays silent — the bug surfaces in production, when someone else's order gets cancelled.
Solution. Attach a "brand" to the type that exists only at compile time:
type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };
// tag once, at the boundary
const toUserId = (id: string) => id as UserId;
const toOrderId = (id: string) => id as OrderId;
function cancelOrder(orderId: OrderId) { /* ... */ }
const userId = toUserId("u_1042");
cancelOrder(userId); // Error: type 'UserId' is not assignable to 'OrderId'Note that as appears in exactly one place — the constructor function. The rest of the code works with branded types, and mixing them up becomes impossible.
When. IDs, tokens, money (mixing up soums and tiyins is a real bug!), anywhere the difference between raw and normalized values matters. If two strings or numbers with different meanings meet in one function — brand them.
3. satisfies: check, but keep the exact type
Problem. The old ways of typing a config object each lose something: as checks nothing at all, while a plain annotation (const c: Config = ...) widens the concrete keys to the general type and autocomplete dies.
Solution. satisfies checks conformance to the shape but keeps the narrow, inferred type:
type ServiceConfig = Record<string, { url: string; timeout: number }>;
// Bad: "as" checks nothing
const legacy = { billing: { url: "https://billing.internal" } } as ServiceConfig;
legacy.billing.timeout; // present in the type, absent in the value — undefined at runtime
// Good: the shape is checked, the concrete keys survive
const config = {
billing: { url: "https://billing.internal", timeout: 5000 },
auth: { url: "https://auth.internal", timeout: 3000 },
} satisfies ServiceConfig;
config.auth.timeout; // autocomplete offers only the real keysThe difference is simple: as says "trust me", satisfies says "check me, but don't forget what you know".
When. Config objects, route maps, i18n dictionaries, themes — anywhere you need shape checking but cannot afford to lose autocomplete over the concrete keys.
4. unknown vs any: at the boundary, verify — don't trust
Problem. any is an escape hatch out of the type system, and it is contagious: every value derived from the any that JSON.parse returned is also any, and the error spreads across the whole module. The compiler keeps saying "OK" to all of it.
Solution. At the boundary use unknown instead of any — it forces you to check the value before using it:
function parseJson(text: string): unknown {
return JSON.parse(text); // deliberately make the result unknown
}
const data = parseJson(rawBody);
// data.email — Error: 'data' is of type 'unknown'
if (
typeof data === "object" && data !== null &&
"email" in data && typeof data.email === "string"
) {
sendEmail(data.email); // inside this block email is definitely a string
}any says "I don't care", unknown says "prove it first". That is the whole difference.
When. Every external boundary: JSON.parse, fetch responses, localStorage, environment variables, queue messages. If manual narrowing feels tedious — the next pattern is exactly about that.
5. Runtime validation at the boundary: zod
Problem. const user = (await res.json()) as User is not a type — it is a hope. Types are erased at runtime: if the backend renames a field or starts sending null, as will not protect you. "It has a type" does not mean "the right thing arrived".
Solution. Parse against a schema at the boundary — and derive the static type from that same schema:
import { z } from "zod";
const OrderSchema = z.object({
id: z.string(),
amount: z.number().positive(),
status: z.enum(["pending", "paid", "cancelled"]),
});
type Order = z.infer<typeof OrderSchema>; // the type is derived from the schema
async function fetchOrder(id: string): Promise<Order> {
const res = await fetch(`/api/orders/${id}`);
const result = OrderSchema.safeParse(await res.json());
if (!result.success) {
// the contract is broken — fail loudly instead of continuing silently
throw new Error(`Order API contract violated: ${result.error.message}`);
}
return result.data; // now it really is an Order
}One schema gives you both the runtime check and the static type — no need to write them separately and keep them in sync. valibot or ArkType do the same job: the idea matters, not the library.
When. Any input you do not control: external APIs, webhooks, user forms, queue messages. Not needed between internal functions — validate once at the boundary, and you can trust the types inside.
6. Template literal types: turn a string format into a type
Problem. Event names follow a convention like "user.created", but as long as their type is plain string, the typo "user.craeted" happily survives until runtime.
Solution. Make the format itself the type:
type Entity = "user" | "order" | "payment";
type Action = "created" | "updated" | "deleted";
type EventName = `${Entity}.${Action}`; // 9 allowed combinations
function emit(event: EventName, payload: unknown) { /* ... */ }
emit("payment.created", payload); // OK
emit("payment.done", payload); // Error: no such eventAdd a new entity and every combination appears automatically — no manual list to maintain.
When. Short strings with a constrained format: event names, cache key prefixes, permission names (like "orders:read"). But in moderation: when elaborate string-parsing types become unreadable, that is debt too — just in another shape.
Conclusion
All 6 patterns share one logic: the earlier a bug is caught, the cheaper it is. any works the other way around — it pushes the bug to the most expensive place, production. That is why I treat any as debt: you save 5 minutes today and pay it back with a day of debugging.
You do not have to adopt everything at once. Make sure strict: true is on, replace boundary anys with unknown, put zod on the one API that hurts the most — you will feel the difference within the first week. Don't just write TypeScript — use it: the compiler should work for you, not against you.