Strong typing
TypeScript isn't "JavaScript with autocomplete". It's the only tool that checks your app's design without running the app. The narrower the type, the more bugs it catches for free.
The goal of this page: make the compiler refuse the wrong state, instead of you reviewing the wrong state.
Rule zero: zero any
"@typescript-eslint/no-explicit-any": "error"
any turns the compiler off. Worse: it leaks — an any variable contaminates
everything it touches, and the error shows up three files later, at runtime.
There is no legitimate case. There are two replacements:
| Situation | Use |
|---|---|
I don't know what it is (network response, JSON.parse) |
unknown + validation |
| I accept any object | Record<string, unknown> |
| A real generic | <T> with a constraint |
// ❌ any: the error will show up in another file
function handle(payload: any) {
return payload.user.name.toUpperCase();
}
// ✅ unknown: the compiler makes you prove the shape before using it
function handle(payload: unknown): string {
const parsed = userSchema.parse(payload);
return parsed.name.toUpperCase();
}
as is any under another name
payload as User checks nothing — it's you promising. as is only acceptable in
three places: as const, narrowing unknown after a type guard, and working
around a known library typing limitation (with a JSDoc explaining it). Outside
that, as is where bugs get in.
Types derived from the schema, never written twice
The most common mistake in a zod app: writing the interface and the schema
separately.
interface Order {
id: string;
status: string;
totalCents: number;
}
const orderSchema = z.object({
id: z.string(),
status: z.string(),
total_cents: z.number(), // ⚠️ diverged and nobody noticed
});
export const orderSchema = z.object({
id: z.string().uuid(),
status: z.enum(["pending", "paid", "shipped", "delivered", "cancelled"]),
totalCents: z.number().int().nonnegative(),
});
export type Order = z.infer<typeof orderSchema>;
export type OrderStatus = Order["status"];
One source of truth. Changing the schema changes the type, and the compiler points at every place that must follow. See Data flow.
z.enum instead of z.string()
status: z.string() accepts "banana". z.enum([...]) makes the component's
switch exhaustive and turns a backend inventing a new value into an error at the
edge, not a blank screen.
Discriminated unions: the impossible state stops existing
This is the highest-value typing win in a React app, and the most ignored.
interface OrderState {
isLoading: boolean;
data?: Order;
error?: Error;
}
{ isLoading: true, data: order, error: err } compiles. What does the screen
show?
type OrderState =
| { status: "loading" }
| { status: "success"; data: Order }
| { status: "error"; error: Error };
switch (state.status) {
case "loading":
return <Spinner />;
case "success":
return <OrderCard order={state.data} />; // `data` exists here, guaranteed
case "error":
return <ErrorState message={state.error.message} />;
}
Inside each case, TypeScript knows exactly which fields exist. No state.data!, no
defensive if (!state.data) return null.
Exhaustiveness the compiler enforces
/** Fails to compile when a new OrderState variant is added and not handled. */
function assertNever(value: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
}
switch (state.status) {
case "loading":
return <Spinner />;
case "success":
return <OrderCard order={state.data} />;
case "error":
return <ErrorState message={state.error.message} />;
default:
return assertNever(state);
}
Add { status: "empty" } to the union and the build breaks at assertNever,
pointing at the exact spot. That's the difference between finding out at compile time
and finding out in production.
Props: model what is valid
The same reasoning applies to component props:
// ❌ allows `variant="link"` without href, and href together with onClick
interface ButtonProps {
variant?: string;
href?: string;
onClick?: () => void;
}
// ✅ either a link with href, or a button with onClick — never both
type ButtonProps =
| { as: "link"; href: string; children: ReactNode }
| { as?: "button"; onClick: () => void; children: ReactNode };
And for variants, a string union instead of booleans — the pattern of every SDK
component:
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
export type ButtonSize = "xs" | "sm" | "md" | "lg" | "xl";
Autocomplete shows the options, a typo doesn't compile, and primary danger does not
exist.
The utility types worth memorizing
type Order = { id: string; code: string; status: OrderStatus; totalCents: number };
type OrderPreview = Pick<Order, "id" | "code">; // only those fields
type OrderDraft = Omit<Order, "id">; // everything but id
type OrderPatch = Partial<Order>; // everything optional
type FullOrder = Required<OrderDraft>; // everything required
type OrderReadonly = Readonly<Order>; // no mutation
type OrdersById = Record<string, Order>; // a map
type OrderStatus2 = Order["status"]; // indexed access
type ListResult = Awaited<ReturnType<typeof listOrders>>; // async return type
The pattern: derive, don't retype. A hand-written OrderDraft goes out of sync
with Order on the first change; Omit<Order, "id"> never does.
satisfies: validates without widening
// ❌ the const's type becomes Record<string, string> — exact keys are lost
const STATUS_LABEL: Record<OrderStatus, string> = {
pending: "Pending",
paid: "Paid",
shipped: "Shipped",
delivered: "Delivered",
cancelled: "Cancelled",
};
// ✅ validates that it covers every OrderStatus AND keeps the literal keys
const STATUS_LABEL = {
pending: "Pending",
paid: "Paid",
shipped: "Shipped",
delivered: "Delivered",
cancelled: "Cancelled",
} satisfies Record<OrderStatus, string>;
With satisfies you get both: if a status is missing, it's a compile error; and
typeof STATUS_LABEL still knows the exact keys for autocomplete and for deriving
types.
A lookup table kills a giant switch
STATUS_LABEL[status] replaces five case branches. And with satisfies, adding
a new status to the union forces you to fill the table. That pair
(union + satisfies Record<>) is the cheapest way to make the compiler enforce
completeness.
Generics with a constraint, not loose generics
// ❌ T guarantees nothing — `item.id` may not exist
function indexById<T>(items: T[]): Record<string, T> {
return Object.fromEntries(items.map((i) => [i.id, i])); // error or any
}
// ✅ the constraint documents the requirement and the compiler enforces it
function indexById<T extends { id: string }>(items: T[]): Record<string, T> {
return Object.fromEntries(items.map((item) => [item.id, item]));
}
A constraint is executable documentation: whoever calls it with { uuid: string }[]
gets an error at the call site, with the right message.
Explicit null
Implicit undefined is TypeScript's NullPointerException. Keep strict on (the
template already does) and handle it at the edge:
// ✅ the type says it may not find anything; the caller must handle it
export async function findOrder(id: string): Promise<Order | null> {
const raw = await api.get<unknown>(`/orders/${id}`).catch(() => null);
return raw === null ? null : parseResponse(orderSchema, raw, "findOrder");
}
And on the consuming side, prefer an early return over cascading ?.:
if (!order) return <EmptyState title="Order not found" />;
// from here down `order` is Order, with no `?.` on any line
! (non-null assertion) is a promise, not a check
order!.code is you guaranteeing the compiler something it couldn't prove. When
you're right, it works; when you're wrong, it's undefined in production. Use an
early return.
An empty collection is [], not null
// ✅
function listOrders(): Promise<Order[]>; // no results → []
interface OrderResponse { items: Order[] } // default: []
// ❌
function listOrders(): Promise<Order[] | null>; // now every caller checks null
"No results" is a successful result. Returning [] makes the component's .map()
work without an if, and it's the convention the Tempest backend follows too.
Where to check
npx tsc -b --noEmit # includes the tests — a type bug in a test is a bug too
npx tempest lint
npx tempest doctor # checks tsconfig strict, alias, env, CSS
typecheck is the cheapest CI gate: it runs in seconds and catches what no test
would catch without writing the case.
Recap
- Zero
any.unknownat the edge + validation;asonly in three rare cases. - Types derived from the zod schema (
z.infer), never written twice. - A discriminated union erases the impossible state;
assertNeverenforces exhaustiveness at build time. - Props model what's valid: string
union, not booleans; mutually exclusive variants as a union of objects. - Derive with
Pick/Omit/Partial/ReturnType; validate withsatisfies. - Generics with a constraint; explicit null with early returns; an empty
collection is
[].
Next: Testing strategy.