TypeScript Patterns I Reused in This Codebase
Practical TypeScript patterns I keep coming back to, with examples from this actual codebase.
Why Patterns Matter
As I learn TypeScript more deeply, I keep finding patterns that make code safer and easier to reason about. These are the ones I currently reach for most often, with examples pulled from this site's codebase so the snippets use concrete imports, not toy foo/bar placeholders.
Discriminated Unions
The contact form server action returns a ContactState that is a discriminated union on success. On success there is no errors field in the type; on failure, errors is optional (present for validation failures, absent for rate limits or delivery errors):
export type ContactState =
| { success: true; message: string }
| {
success: false;
message: string;
errors?: Record<string, string[]>;
};The success literal acts as the discriminant, so if (state.success) narrows the union and TypeScript knows whether errors can exist. That is stricter than a single object with success: boolean, where errors would stay in the type on both branches and if (state.success) would not narrow.
This is also clearer than stacking optional data and error fields on one shape, because the variants encode which fields are meaningful for each outcome.
Const Assertions for Curated Lists
The homepage only shows a specific subset of projects. Instead of filtering by a boolean flag, I define the exact slugs with as const:
const HOMEPAGE_FEATURED_SLUGS = [
"stringflux",
"research-radar",
"full-swing-tech-support",
] as const;This gives me a readonly tuple of string literals instead of string[]. The lookup function can then throw at startup if a slug is missing from the data, rather than silently returning an empty card at runtime. The const assertion turns a potential silent bug into an immediate error.
Composable Zod Schemas for Environment Config
The env parsing in this site uses a pattern I really like: one base schema that parses all env vars, then smaller schemas layered on top for specific features:
const appEnvSchema = z.object({
DATABASE_URL: optionalTrimmedString,
AUTH_SECRET: optionalTrimmedString,
RESEND_API_KEY: optionalTrimmedString,
// ... all vars, all optional at parse time
});
const adminAuthEnvSchema = z.object({
DATABASE_URL: z.string().min(1),
AUTH_SECRET: z.string().min(1),
AUTH_GITHUB_ID: z.string().min(1),
AUTH_GITHUB_SECRET: z.string().min(1),
});
const contactDeliveryEnvSchema = z.object({
RESEND_API_KEY: z.string().min(1),
CONTACT_FROM_EMAIL: z.string().email(),
CONTACT_TO_EMAIL: z.string().email(),
});The base schema always succeeds because every var is optional. Feature-specific schemas are strict and only checked when that feature's code path runs. This means the site boots cleanly without a database, but the admin routes know at call time whether they have what they need.
The composable part: getContactDeliveryEnv() parses the base schema first, then runs the strict schema against the result. If it fails, it returns null instead of throwing. The calling code checks for null and shows a graceful fallback.
Exhaustive Checks
When I have a string union, I want the compiler to catch it if I add a new case and forget to handle it:
type Status = "idle" | "loading" | "success" | "error";
function getStatusMessage(status: Status): string {
switch (status) {
case "idle":
return "Ready";
case "loading":
return "Loading...";
case "success":
return "Done!";
case "error":
return "Something went wrong";
default: {
const _exhaustive: never = status;
return _exhaustive;
}
}
}If I add "retrying" to the union later, TypeScript flags the default branch because never can't be assigned from "retrying". I haven't used this pattern heavily in this codebase yet, but it's one I reach for any time a union drives conditional logic.
What I've Learned So Far
The biggest shift for me has been treating types as guardrails that shrink what can compile: the env parsing pattern is a good example. Parsed shapes tell you which features are actually configured, so missing keys surface at the call site while you still have local context, long before a production stack trace would.
I'm still learning. None of this is exotic TypeScript, but these patterns already caught real bugs in this repo.