Advanced TypeScript for React Developers | ZextOverse
Advanced TypeScript for React Developers
TypeScript isn't just "JavaScript with types." For React developers, it's a design language for component APIs — one that makes implicit contracts explicit, prevents entire classes of bugs at zero runtime cost, and turns your editor into a collaborator.
Most React developers reach a TypeScript plateau. They know how to type props, annotate useState, and occasionally wrestle with event handler types. But they're leaving the most powerful parts of the language untouched.
This article is for developers past that plateau — or ready to get past it. We'll cover the TypeScript features that meaningfully improve React codebases: not syntax trivia, but patterns that reduce bugs, improve API ergonomics, and make refactoring safer.
Each section focuses on a concrete problem, then shows how advanced TypeScript solves it.
1. Discriminated Unions: Modeling Component States Honestly
React components often represent multiple mutually exclusive states. A data-fetching component can be loading, errored, or successful — never two at once. Developers commonly model this with optional fields:
// ❌ This allows impossible states
interface DataProps {
isLoading: boolean;
error?: Error;
data?: User[];
}
This type allows { isLoading: true, error: someError, data: someData } — a state that can't actually exist. TypeScript won't catch it, but your component will behave unpredictably when it occurs.
Discriminated unions close this gap:
// ✅ Only valid states are representable
type DataState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "error"; error: Error }
| { status: "success"; data: T };
function UserList({ status, ...rest }: DataState<User[]>) {
if (status === "loading") return <Spinner />;
if (status === "error") return <ErrorMessage error={rest.error} />;
if (status === "success") return <List items={rest.data} />;
return null;
}
After narrowing status, TypeScript knows exactly which fields are available. You can't accidentally access rest.data in the "error" branch — the type system prevents it. This pattern is especially powerful when combined with a state machine library like XState or Zustand with typed slices.
2. Generic Components: Writing APIs That Teach Themselves
Generic components are the difference between a reusable component library and a truly ergonomic one. The goal is for TypeScript to infer types from usage rather than requiring explicit annotations.
A Typed List Component
interface ListProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor: (item: T) => string;
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map((item, index) => (
<li key={keyExtractor(item)}>{renderItem(item, index)}</li>
))}
</ul>
);
}
// Usage — T is inferred as User automatically
<List
items={users}
keyExtractor={(user) => user.id} // user is User, not unknown
renderItem={(user) => <UserCard user={user} />}
/>
Share this article:
The T type parameter flows through the whole component. When you pass users: User[], TypeScript infers T = User — and then enforces that keyExtractor and renderItem receive User arguments. You never need to write List<User> explicitly.
Constrained Generics with extends
Sometimes you need a generic that's flexible but not unlimited:
// T must have an `id` field — no more, no less
function SelectList<T extends { id: string; label: string }>({
items,
onSelect,
}: {
items: T[];
onSelect: (item: T) => void;
}) {
return (
<ul>
{items.map((item) => (
<li key={item.id} onClick={() => onSelect(item)}>
{item.label}
</li>
))}
</ul>
);
}
This component works with any object that has id and label, and onSelect still receives the full T type — not just those two fields.
3. Conditional Types: Props That Depend on Each Other
Some component APIs have props that are only valid in certain combinations. A Button that becomes a link when given an href. A Tooltip that requires content only when enabled is true. Trying to model these with optional props leads to impossible states and runtime errors.
Conditional types and overloaded interfaces express these relationships at the type level.
The Button/Link Pattern
type ButtonAsButton = {
as?: "button";
href?: never;
onClick: React.MouseEventHandler<HTMLButtonElement>;
};
type ButtonAsAnchor = {
as: "a";
href: string;
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
};
type ButtonProps = (ButtonAsButton | ButtonAsAnchor) & {
children: React.ReactNode;
variant?: "primary" | "ghost" | "danger";
};
function Button({ as = "button", children, variant = "primary", ...rest }: ButtonProps) {
if (as === "a") {
const { href, onClick } = rest as ButtonAsAnchor;
return <a href={href} onClick={onClick} className={`btn btn--${variant}`}>{children}</a>;
}
const { onClick } = rest as ButtonAsButton;
return <button onClick={onClick} className={`btn btn--${variant}`}>{children}</button>;
}
// ✅ Works
<Button onClick={handleClick}>Submit</Button>
<Button as="a" href="/dashboard">Go to Dashboard</Button>
// ❌ TypeScript error: href requires as="a"
<Button href="/dashboard">Submit</Button>
// ❌ TypeScript error: onClick is required when as is "button"
<Button>Submit</Button>
The never type in ButtonAsButton is the key: it tells TypeScript that href is not just optional — it's forbidden in that variant. This catches misuse at compile time.
4. Template Literal Types: Typed CSS and Event Names
TypeScript 4.1 introduced template literal types, which are particularly useful in React for event handlers, CSS class generation, and design system tokens.
Typed CSS Utility Classes
type Spacing = 0 | 1 | 2 | 4 | 6 | 8 | 12 | 16;
type Side = "t" | "b" | "l" | "r" | "x" | "y";
type PaddingClass = `p${Side}-${Spacing}`;
type MarginClass = `m${Side}-${Spacing}`;
type SpacingClass = PaddingClass | MarginClass;
interface BoxProps {
className?: SpacingClass;
children: React.ReactNode;
}
Now your className prop only accepts valid Tailwind-like spacing utilities. Typos are caught at compile time, not discovered when styles don't apply.
This pattern is how React's own type definitions generate their event handler props — you can use the same technique for custom component APIs.
5. infer and Type Utilities: Extracting Types From Components
When working with third-party component libraries or large codebases, you often need to derive types from existing components rather than redefining them.
ComponentProps and ComponentRef
React's built-in utility types are the first tool to reach for:
import { ComponentProps, ComponentRef } from "react";
// Extract props from any component
type ButtonProps = ComponentProps<typeof Button>;
type InputProps = ComponentProps<"input">; // Also works for HTML elements
// Type a ref to a component
const ref = useRef<ComponentRef<typeof FancyInput>>(null);
Building Your Own Extraction Utilities
// Extract the type of a specific prop from a component
type PropType<TComponent, TProp extends keyof ComponentProps<TComponent>> =
ComponentProps<TComponent>[TProp];
type ButtonVariant = PropType<typeof Button, "variant">;
// → "primary" | "ghost" | "danger"
// Extract return type of an async function
type AsyncReturnType<T extends (...args: any) => Promise<any>> =
T extends (...args: any) => Promise<infer R> ? R : never;
async function fetchUser(id: string): Promise<User> { /* ... */ }
type FetchedUser = AsyncReturnType<typeof fetchUser>;
// → User
Unwrapping React Contexts
function createContext<T>() {
const Context = React.createContext<T | undefined>(undefined);
function useContext(): T {
const value = React.useContext(Context);
if (value === undefined) {
throw new Error("useContext must be used within Provider");
}
return value;
}
return [Context.Provider, useContext] as const;
}
const [ThemeProvider, useTheme] = createContext<{ mode: "light" | "dark" }>();
The as const assertion at the end makes TypeScript infer a tuple type rather than an array, preserving the specific types of each element.
6. Typed Custom Hooks: Making the Contract Explicit
Custom hooks are where React logic lives — and where TypeScript can provide the most value if you're deliberate about return types.
Overloaded Hook Signatures
Sometimes a hook should return different types based on its arguments. TypeScript function overloads express this:
Understanding why TypeScript sometimes rejects valid code is as important as knowing the workarounds.
Covariance vs. Contravariance
TypeScript is covariant for most types: a Dog[] is assignable to Animal[] because Dog extends Animal. But function parameters are contravariant: a function that accepts Animal is assignable to a function that accepts Dog — not the other way.
type Handler<T> = (value: T) => void;
// ✅ Works — a handler of Animal can handle Dogs
const animalHandler: Handler<Animal> = (a) => console.log(a.name);
const dogHandler: Handler<Dog> = animalHandler;
// ❌ Fails — a handler of Dog can't handle all Animals
const dogOnlyHandler: Handler<Dog> = (d) => console.log(d.breed);
const animalHandler2: Handler<Animal> = dogOnlyHandler; // Error
This matters when you're passing event handlers and callbacks between components. If TypeScript rejects an assignment involving callbacks, contravariance is usually the reason — and as is rarely the right fix. The right fix is usually adjusting the type parameter or the call site.
When as Is Acceptable
// Acceptable: you know more than TypeScript about an external API response
const data = response.data as UserApiResponse;
// Acceptable: narrowing after a type guard the compiler can't verify
const canvas = ref.current as HTMLCanvasElement; // after null check
// Not acceptable: silencing a real type error
const count = someString as unknown as number; // red flag
The rule of thumb: as for assertions about what you know to be true, not for papering over mistakes.
8. The satisfies Operator: Validation Without Widening
One of TypeScript's most useful recent additions is satisfies. It checks that a value matches a type, but unlike a type annotation, it doesn't widen the type — you keep the literal type information.
const theme = {
colors: {
primary: "#3B82F6",
danger: "#EF4444",
success: "#10B981",
},
spacing: {
sm: 4,
md: 8,
lg: 16,
},
} satisfies Record<string, Record<string, string | number>>;
// With annotation, theme.colors.primary is `string`
// With satisfies, theme.colors.primary is `"#3B82F6"` — literal!
theme.colors.primary; // "#3B82F6"
// But this still fails at compile time:
const bad = {
colors: { primary: true }, // ❌ boolean is not string | number
} satisfies Record<string, Record<string, string | number>>;
For design systems, this is invaluable: you validate your token object against a schema while retaining autocomplete on the exact values.
9. Declaration Merging and Module Augmentation
When integrating third-party libraries, you sometimes need to extend existing types without forking the package.
Extending React.CSSProperties
// In a global .d.ts file
declare module "react" {
interface CSSProperties {
// Allow CSS custom properties
[key: `--${string}`]: string | number;
}
}
// Now this is valid:
<div style={{ "--accent-color": "#3B82F6", color: "var(--accent-color)" }} />
Extending Third-Party Component Props
declare module "some-ui-library" {
interface ButtonProps {
analyticsId?: string; // Add your tracking prop to all Buttons
}
}
This technique is how mature codebases extend libraries without wrapping every component.
10. Putting It Together: A Typed Data Table
Here's a component that combines several patterns — generics, discriminated unions, and conditional types — into a realistic use case:
The discriminated union on selectable ensures that onSelect is required when selectable={true} and forbidden when it's false — exactly the kind of constraint that would otherwise live only in documentation.
Mental Models for Advanced TypeScript
After all the syntax, a few guiding principles:
Types are sets, not labels.string isn't a tag on a variable — it's the set of all possible string values. "light" | "dark" is a smaller set. Unions expand sets; intersections shrink them.
Narrow, don't cast. When TypeScript can't prove a type, the instinct is often to cast with as. The better instinct is to add a runtime check that teaches TypeScript to narrow the type — a type guard, an instanceof check, or a truthiness check. This gives you safety at runtime too.
Types at the boundary; inference inside. Be explicit where data enters your system — API responses, form inputs, event handlers. Let TypeScript infer types inside your components and hooks. Annotating everything is noisy and often less accurate than letting inference do its job.
Make impossible states unrepresentable. The phrase is Yaron Minsky's, but it applies perfectly to React. If your types allow states that your UI can't handle, your types are lying. Discriminated unions are usually the fix.
Conclusion
Advanced TypeScript in React isn't about memorizing type syntax — it's about learning to express your component's intentions in a way the compiler can verify. A well-typed component prop API is documentation that never goes stale, a contract that the entire team benefits from, and a safety net that catches bugs before they reach production.
The investment compounds. A generic, well-typed List component saves a few minutes today. Six months from now, when someone changes the shape of your User type, every call site that's using that component will update or error immediately — catching a class of bugs that would otherwise have been discovered in QA, in production, or by a user.