TypeScript Best Practices Pair with React Best Practices
When working with React components (.tsx, .jsx files or @react imports), always load react-best-practices alongside this skill. This skill covers TypeScript fundamentals; React-specific patterns (effects, hooks, refs, component design) are in the dedicated React skill.
Type-First Development
Types define the contract before implementation. Follow this workflow:
Define the data model - types, interfaces, and schemas first Define function signatures - input/output types before logic Implement to satisfy types - let the compiler guide completeness Validate at boundaries - runtime checks where data enters the system Make Illegal States Unrepresentable
Use the type system to prevent invalid states at compile time.
Discriminated unions for mutually exclusive states:
// Good: only valid combinations possible
type RequestState
// Bad: allows invalid combinations like { loading: true, error: Error }
type RequestState
Branded types for domain primitives:
type UserId = string & { readonly __brand: 'UserId' }; type OrderId = string & { readonly __brand: 'OrderId' };
// Compiler prevents passing OrderId where UserId expected
function getUser(id: UserId): Promise
function createUserId(id: string): UserId { return id as UserId; }
Const assertions for literal unions:
const ROLES = ['admin', 'user', 'guest'] as const; type Role = typeof ROLES[number]; // 'admin' | 'user' | 'guest'
// Array and type stay in sync automatically function isValidRole(role: string): role is Role { return ROLES.includes(role as Role); }
Required vs optional fields - be explicit:
// Creation: some fields required type CreateUser = { email: string; name: string; };
// Update: all fields optional
type UpdateUser = Partial
// Database row: all fields present type User = CreateUser & { id: UserId; createdAt: Date; };
Module Structure
Prefer smaller, focused files: one component, hook, or utility per file. Split when a file handles multiple concerns or exceeds ~200 lines. Colocate tests with implementation (foo.test.ts alongside foo.ts). Group related files by feature rather than by type.
Functional Patterns
Prefer const over let; use readonly and Readonly
Explicit failure for unimplemented logic:
export function buildWidget(widgetType: string): never {
throw new Error(buildWidget not implemented for type: ${widgetType});
}
Exhaustive switch with never check:
type Status = "active" | "inactive";
export function processStatus(status: Status): string {
switch (status) {
case "active":
return "processing";
case "inactive":
return "skipped";
default: {
const _exhaustive: never = status;
throw new Error(unhandled status: ${_exhaustive});
}
}
}
Wrap external calls with context:
export async function fetchWidget(id: string): Promise/api/widgets/${id});
if (!response.ok) {
throw new Error(fetch widget ${id} failed: ${response.status});
}
return response.json();
}
Debug logging with namespaced logger:
import debug from "debug";
const log = debug("myapp:widgets");
export function createWidget(name: string): Widget { log("creating widget: %s", name); const widget = { id: crypto.randomUUID(), name }; log("created widget: %s", widget.id); return widget; }
Runtime Validation with Zod Define schemas as single source of truth; infer TypeScript types with z.infer<>. Avoid duplicating types and schemas. Use safeParse for user input where failure is expected; use parse at trust boundaries where invalid data is a bug. Compose schemas with .extend(), .pick(), .omit(), .merge() for DRY definitions. Add .transform() for data normalization at parse time (trim strings, parse dates). Include descriptive error messages; use .refine() for custom validation logic. Examples
Schema as source of truth with type inference:
import { z } from "zod";
const UserSchema = z.object({ id: z.string().uuid(), email: z.string().email(), name: z.string().min(1), createdAt: z.string().transform((s) => new Date(s)), });
type User = z.infer
Return parse results to callers (never swallow errors):
import { z, SafeParseReturnType } from "zod";
export function parseUserInput(raw: unknown): SafeParseReturnType
// Caller handles both success and error: const result = parseUserInput(formData); if (!result.success) { setErrors(result.error.flatten().fieldErrors); return; } await submitUser(result.data);
Strict parsing at trust boundaries:
export async function fetchUser(id: string): Promise/api/users/${id});
if (!response.ok) {
throw new Error(fetch user ${id} failed: ${response.status});
}
const data = await response.json();
return UserSchema.parse(data); // throws if API contract violated
}
Schema composition:
const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true }); const UpdateUserSchema = CreateUserSchema.partial(); const UserWithPostsSchema = UserSchema.extend({ posts: z.array(PostSchema), });
Configuration Load config from environment variables at startup; validate with Zod before use. Invalid config should crash immediately. Define a typed config object as single source of truth; avoid accessing process.env throughout the codebase. Use sensible defaults for development; require explicit values for production secrets. Examples
Typed config with Zod validation:
import { z } from "zod";
const ConfigSchema = z.object({ PORT: z.coerce.number().default(3000), DATABASE_URL: z.string().url(), API_KEY: z.string().min(1), NODE_ENV: z.enum(["development", "production", "test"]).default("development"), });
export const config = ConfigSchema.parse(process.env);
Access config values (not process.env directly):
import { config } from "./config";
const server = app.listen(config.PORT); const db = connect(config.DATABASE_URL);
Optional: type-fest
For advanced type utilities beyond TypeScript builtins, consider type-fest:
Opaque
// Branded type (cleaner than manual approach)
type UserId = Opaque
// Deep partial for patch operations
type UserPatch = PartialDeep
// Make specific fields required
type UserWithEmail = SetRequired