React + TypeScript — The Practical Refresher

posted 4 min read

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

  1. TypeScript = developer tooling and documentation. It is erased at runtime.
  2. Runtime validation = required for all external inputs. Use a schema library.
  3. Prefer unknown over any. Narrow as close as possible to the use site.
  4. 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

  1. CI: tsc --noEmit.
  2. Type assertions: use tsd for type-level tests when you publish libs.
  3. 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.

If you read this far, tweet to the author to show them you care. Tweet a Thanks

Great refresher, really practical and concise. How do you usually balance TypeScript type safety with runtime validation in larger projects?

More Posts

Documentation of components in React with TypeScript using Storybook (version 8)

Eduardo Gris - Aug 14

Typescript-eslint + prettier for code standardization in React with Typescript

Eduardo Gris - Jul 12

Clean React code pattern with the useImperativeHandle hook

Tony Edgal - May 20

Starting a new web app with a React project, should you go with React JavaScript or TypeScript?

Sunny - Jun 24

A simple tutorial about creating a useGlobalState hook in React

Aneesh Varadan - Jun 23
chevron_left