This is the short, usable handbook you return to when you need to remember how to model things, avoid gotchas, and ship safely. No theory for the sake of it. Examples are minimal, opinionated, and geared for production.
Types help you encode intent and catch whole classes of bugs before runtime; but they are not runtime validation. Use both.
Setup — sensible baseline
tsconfig essentials
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["DOM", "ES2022"],
"jsx": "react-jsx",
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"exactOptionalPropertyTypes": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "bundler"
}
}
Linting: @typescript-eslint/recommended + enforce no-floating-promises and explicit-module-boundary-types for public APIs. CI: tsc --noEmit as a gate.
Core mental model
- TypeScript = developer tooling and documentation. It is erased at runtime.
- Runtime validation = required for all external inputs. Use a schema library.
- Prefer
unknown over any. Narrow as close as possible to the use site.
- Types are code, treat them like tests. Fail the build on type errors.
Components — practical patterns
Don’t use React.FC by default
It hides things and complicates children typing. Use an explicit props type.
type ButtonProps = { onClick?: () => void; children?: React.ReactNode };
function Button({ onClick, children }: ButtonProps) {
return <button onClick={onClick}>{children}</button>;
}
Props: keep them small and explicit
Prefer composition over huge options bag. Use Pick and Omit to compose.
Events and DOM props
function Input() {
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
console.log(e.target.value);
}
return <input onChange={onChange} />;
}
Polymorphic components
If you need as prop, type it so consumers get correct props:
type AsProp<C extends React.ElementType> = { as?: C };
type PolymorphicProps<C extends React.ElementType, Props = {}> =
Props & AsProp<C> & Omit<React.ComponentPropsWithoutRef<C>, keyof Props | 'as'>;
function Box<C extends React.ElementType = 'div'>({ as, ...rest }: PolymorphicProps<C>) {
const Component = (as ?? 'div') as React.ElementType;
return <Component {...(rest as any)} />;
}
Reason: preserve proper HTML props and autocompletion for consumers.
Advanced typing patterns
Discriminated unions
Best tool for variants with exhaustive checks.
type Item =
| { kind: 'video'; url: string; duration: number }
| { kind: 'article'; url: string; wordCount: number };
function render(i: Item) {
switch (i.kind) {
case 'video': return <Video {...i} />;
case 'article': return <Article {...i} />;
}
}
Generics for reusable components and hooks
When you need reusability, prefer generics to any.
type ListProps<T> = { items: T[]; renderItem: (t: T) => React.ReactNode };
function List<T>({ items, renderItem }: ListProps<T>) {
return <>{items.map(renderItem)}</>;
}
Utility types: Omit, Pick, Partial, Required, ReturnType<T>
Use them sparingly and name intent in the codebase.
State & hooks
useState
When initial value could be null, annotate it.
const [user, setUser] = useState<User | null>(null);
useReducer: type your actions
Action unions give you compile-time exhaustiveness.
type State = { count: number };
type Action = { type: 'inc' } | { type: 'add'; payload: number };
function reducer(s: State, a: Action): State {
switch (a.type) {
case 'inc': return { count: s.count + 1 };
case 'add': return { count: s.count + a.payload };
}
}
Custom hooks: explicit return types
Always annotate exported hooks to lock the API.
function useAuth(): { user: User | null; login: (u: Credentials) => Promise<void> } {
// ...
}
Runtime validation — Zod patterns (short, practical)
Types are compile-time only. Use Zod to validate at boundaries and infer types.
import { z } from 'zod';
const UserSchema = z.object({
id: z.string(),
name: z.string(),
age: z.number().int().nonnegative()
});
type User = z.infer<typeof UserSchema>;
async function fetchUser(id: string): Promise<User> {
const r = await fetch(`/api/users/${id}`);
const raw = await r.json();
return UserSchema.parse(raw); // throws on invalid
}
Use .safeParse when you want to handle errors instead of throwing. Use z.preprocess to coerce strings to numbers at the boundary.
RHF gives performance for big forms; zodResolver gives typed form values.
const schema = z.object({
email: z.string().email(),
age: z.preprocess(v => Number(v), z.number().int().nonnegative())
});
type FormValues = z.infer<typeof schema>;
function ProfileForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { email: '', age: 0 }
});
// ...
}
Always revalidate on the server.
API contracts and sharing types
Single source: define Zod schemas close to API boundary and export z.infer types for frontend use or generate OpenAPI when necessary. Keep transformations explicit: raw → parsed → domain models.
Testing types
- CI:
tsc --noEmit.
- Type assertions: use
tsd for type-level tests when you publish libs.
- Unit-test runtime validators with edge cases.
- Keep heavy type-level code out of hot edit path. Move complex conditional types to isolated files.
as const for literal preservation.
- Avoid excessive type inference in large files; sometimes small explicit types speed up the TS server.
skipLibCheck: true in big monorepos is pragmatic.
Common gotchas and how to fix them
- Literal widening:
const colors = ['red', 'blue'] as const.
React.FC children quirks: prefer explicit children type.
- Accidentally swallowing errors: don’t
catch {}. Wrap and rethrow typed errors.
- Over-asserting with
as: replace with runtime validator or typed assert function.
- Slow TS server: split types into
types/ or schemas/ modules.
Small library of helper snippets
assert function
export function assert(condition: unknown, message?: string): asserts condition {
if (!condition) throw new Error(message ?? 'Assertion failed');
}
safeFetch helper
async function safeFetch<T extends z.ZodTypeAny>(url: string, schema: T) {
const r = await fetch(url);
const raw = await r.json();
const parsed = schema.safeParse(raw);
if (!parsed.success) throw new Error(JSON.stringify(parsed.error.format()));
return parsed.data;
}
Final notes — pragmatic philosophy
TypeScript is not a substitute for good API contracts and runtime checks. Use types to document and catch developer mistakes early. Use runtime schemas to protect users and services. Keep APIs explicit, keep files small, and prefer composition over configuration-on-steroids.