Component architecture that survives three years of feature requests
Every codebase is well organised on day one. The question is what it looks like after forty feature requests have been bolted onto the same Button.
The TypeScript patterns that prevent the most real bugs are discriminated unions for state, branded types for identifiers, schema-derived types for external data, and `satisfies` for configuration objects. Most application code needs these four and very little else.
Because they make invalid combinations of state impossible to construct, so the compiler catches an entire class of bug.
// Every field optional: "loading with data and an error" is representable
type Result = {
loading: boolean;
data?: User[];
error?: Error;
};
// Only valid combinations exist
type Result =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User[] }
| { status: "error"; error: Error };
function render(result: Result) {
switch (result.status) {
case "idle": return null;
case "loading": return <Spinner />;
case "success": return <List items={result.data} />; // data is defined here
case "error": return <Error error={result.error} />; // error is defined here
}
}In the second version you cannot read `data` in the error branch, cannot forget a case if the switch is exhaustive, and cannot construct a nonsensical state. The first version relies on everyone remembering the rules.
type Brand<T, B> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
const asUserId = (value: string) => value as UserId;
function getOrder(id: OrderId) { /* ... */ }
const userId = asUserId("u_123");
getOrder(userId); // Error: UserId is not assignable to OrderIdEvery identifier in your system is a string, which means the compiler cannot help when they get swapped. Branding costs a few lines and eliminates a category of bug that is genuinely difficult to spot in review.
Because a hand-written interface and the runtime validation it describes will eventually disagree, and nothing will tell you.
import { z } from "zod";
const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
role: z.enum(["admin", "member"]),
createdAt: z.coerce.date(),
});
// One definition produces both the runtime check and the static type
type User = z.infer<typeof UserSchema>;
export async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return UserSchema.parse(await response.json()); // throws on mismatch
}type Route = { path: string; auth: boolean };
// as: checked, but the specific keys are lost
const routes = {
home: { path: "/", auth: false },
admin: { path: "/admin", auth: true },
} as Record<string, Route>;
routes.hom; // no error — Record<string, …> accepts any key
// satisfies: checked AND the literal keys are preserved
const routes = {
home: { path: "/", auth: false },
admin: { path: "/admin", auth: true },
} satisfies Record<string, Route>;
routes.hom; // Error: property does not existUse `satisfies` for configuration objects, route maps, theme tokens and anything where you want validation against a shape without losing the precise inferred type.
The purpose of types is to make bugs impossible and intent obvious. Type-level programming that achieves the first while destroying the second is a net loss on a team.
Enable strict mode always. Beyond that, `noUncheckedIndexedAccess` catches real bugs and is worth the friction; `exactOptionalPropertyTypes` is stricter than most codebases need. Turn them on for new code first if retrofitting.
Occasionally, at an untyped third-party boundary, with a comment explaining why. `unknown` followed by validation is almost always the better choice, because it forces the check rather than skipping it.
Yes, at the network boundary. The deployed API may be a version behind, and shared types only guarantee that both sides compiled — not that the running code agrees.
Incrementally. Enable strict for new files, type the data layer first since it catches the most, and convert modules as you touch them rather than treating it as a separate project.
ROVQIX Engineering
Engineering team, ROVQIX
The ROVQIX engineering team builds and maintains web platforms, APIs and infrastructure for clients across SaaS, ecommerce and enterprise. These notes come out of real production work — deploys, incidents, migrations and audits.
ROVQIXdesigns and builds production web platforms — Next.js front ends, Node.js APIs and the infrastructure behind them. Tell us what you're building and we'll scope it with you.
Every codebase is well organised on day one. The question is what it looks like after forty feature requests have been bolted onto the same Button.
An API is a product with developers as users. Most of what makes one good is consistency, not cleverness.
Trust in software is built in the unglamorous states: what happens when something fails, and whether you can undo it.
No spam. Just the occasional case study and craft breakdown.