Follows the type, error, and assertion law in code-law. This skill covers language-specific idioms only.
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<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
// Bad: allows invalid combinations like { loading: true, error: Error } type RequestState = { loading: boolean; data?: T; error?: Error; };
**Branded types for domain primitives:**
```ts
type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };
// Compiler prevents passing OrderId where UserId expected
function getUser(id: UserId): Promise<User> { /* ... */ }
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);
}
Exhaustive switch with never check:
type Status = "active" | "inactive";
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}`);
}
}
}
Restrict who can do it — when only one scope may perform an effect, keep its functions unexported and export only the scope that performs it; a branded capability that the scope alone creates makes an out-of-scope call a type error. Hold module boundaries with ESLint's shipped no-restricted-imports rule. Correct types are the proof.
Runtime Validation with Zod
- Define schemas as single source of truth; infer TypeScript types with
z.infer<>. Avoid duplicating types and schemas. - Use
safeParsefor user input where failure is expected; useparseat 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).
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<typeof UserSchema>;
// Strict parsing at trust boundaries — throws if API contract violated
export async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`fetch user ${id} failed: ${response.status}`);
}
return UserSchema.parse(await response.json());
}
// Caller handles both success and error from user input
const result = UserSchema.safeParse(formData);
if (!result.success) {
setErrors(result.error.flatten().fieldErrors);
return;
}
Test Doubles
toHaveBeenCalledWith / toHaveBeenNthCalledWith on a vi.fn() standing in for your own module assert the call sequence, not the result — the change-detector shape in testing-best-practices. Mock at the network edge (msw) and assert the value the caller receives.
Optional: type-fest
For advanced type utilities beyond TypeScript builtins, consider type-fest:
Opaque<T, Token>- cleaner branded types than manual& { __brand }patternPartialDeep<T>- recursive partial for nested objectsReadonlyDeep<T>- recursive readonly for immutable dataSetRequired<T, K>/SetOptional<T, K>- targeted field modificationsSimplify<T>- flatten complex intersection types in IDE tooltips
import type { Opaque, PartialDeep } from 'type-fest';
type UserId = Opaque<string, 'UserId'>;
type UserPatch = PartialDeep<User>;