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.
Forms — React Hook Form + Zod (practical combo)
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.
Developer experience & performance
- 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.