TypeScript React — Missing Generic Crashed Checkout
Blank white screen after payment? A missing generic annotation caused it — use expect-type to catch this silently failing error, unlike GFG's basic guides..
20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- Generic components enforce type contracts across data shapes at compile time
- Discriminated unions model finite states (loading/success/error) with zero ambiguity
- React.forwardRef + generics = typed refs without
- TypeScript infers from usage, but complex generics need explicit annotations sometimes
- Common trap: over-constraining generics when simpler overloads work better
Imagine you're building with LEGO. Plain React is like LEGO with no instructions — any piece can technically snap onto any other, but you'll find out it's wrong only when the model collapses. TypeScript is the instruction manual that says 'this blue 2x4 brick only connects to these specific pieces' — before you've even started building. It turns runtime crashes into editor squiggles you fix in seconds, not 3am production incidents.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
React gives you freedom. TypeScript gives you guardrails. Together they give you something rare in frontend engineering: confidence that a refactor won't silently break a prop three components deep. In a codebase with a dozen engineers, untyped props are a game of telephone — by the time the wrong shape reaches the component that actually needs it, the original author has left the company. TypeScript closes that gap and makes intent explicit at the component boundary.
The problem TypeScript solves in React isn't just catching typos. It's expressing contracts. A generic data table shouldn't accept 'any data' — it should accept 'an array of T, and a set of column definitions that know how to read properties of T'. Without TypeScript that constraint lives only in a comment nobody reads. With TypeScript it's enforced by the compiler, autocompleted in the editor, and self-documenting in the type signature.
After reading this article you'll be able to type generic components that work across multiple data shapes, model complex UI state with discriminated unions so impossible states become compiler errors, forward refs with full type safety, and avoid the five performance and type-inference traps that catch even experienced React + TypeScript developers off guard. These are patterns straight from production codebases — not toy examples.
What is TypeScript with React?
TypeScript with React lets you bake type safety into component boundaries. Instead of runtime prop‑checking with PropTypes, you encode contracts at compile time. The real win: when a prop shape changes, every consumer lights up with errors — not silent breakage. In practice, that means a generic DataTable can accept any data shape and enforce column definitions that know how to read fields of T. This isn't just about catching null dereferences; it's about making impossible states unrepresentable. For instance, a modal that shows either a loading spinner, an error message, or content should express that as a discriminated union — TypeScript then guarantees you can't accidentally render content while loading.
{id: number, name: string} will break silently if a column tries to access a missing field — but only at runtime.extends to prevent an all‑any escape hatch.extends SomeBaseTypekeyof T to enforce valid field namesDiscriminated Unions for Complex State Management
A state machine inside a React component is common: loading, success, error. The naive approach uses three separate booleans — but that lets you accidentally set both loading and error to true. A discriminated union collapses these into a single type that exactly one branch can hold. Use the status or state property as the discriminant and map over each variant. The payoff: every render path is explicit, and TypeScript will yell if you try to access data when status is 'error'.
- Loading: no data yet, no error
- Error: something went wrong, no data
- Success: data is ready, no error
loading and success. The UI showed a spinner on top of the data — users couldn't click buttons.Forwarding Refs with Type Safety
React.forwardRef lets parent components grab a DOM node from a child. Without generics, the ref type is any and you lose all autocomplete. With a generic forwardRef, you keep the connection between the ref type and the underlying DOM element. The trick: the order of generics is RefType, Props — not Props, RefType. Mix them up and TypeScript will silently infer a wrong type. Common use case: wrapping a third‑party input library where the parent needs to call .focus() imperatively.
forwardRef<RefType, Props>.unknown. The .focus() call on mount threw a runtime error because unknown has no focus method.forwardRef<HTMLInputElement, Props> — ref type first.unknown ref that breaks imperative focus, scroll, and measurement calls.Performance Gotchas: Type Inference Limits and Memoization
TypeScript's type inference is powerful but not free. Deeply nested generics — especially in large union types — can slow down editor autocompletion and increase compilation time. Worse, if a generic component is used inside a React.memo wrapper, the generic type can be lost because memo infers the props as a concrete type. The fix: explicitly annotate the memoised component with the generic signature. Another trap: using React.FC with a generic component forces you to lose the generic parameter — it's a concrete type wrapper. Instead, define the function directly with the generic.
- Define generic components as standalone functions, not with React.FC
- For memoisation, cast the memoised component back to the generic function type
- Use
React.forwardRefsimilarly without the FC wrapper
React.FC wrappers and simplifying generics, compile time dropped to 12 seconds.React.FC.React.FC in generic components — define them as bare functions.React.FC don't mix — the wrapper concretises the type.React.memo also loses generics unless you cast it back.Conditional Props and Type Narrowing with Generics
Sometimes a component's props change based on another prop value. For example, a Button component that optionally renders a tooltip when tooltip prop is provided, and then requires a tooltipPosition. Conditional types let you encode these rules: if tooltip is string, then tooltipPosition is required; otherwise it's forbidden. This eliminates runtime checks and makes the API self‑documenting. The key is to use a discriminated union on the component's props type itself, not just on state.
if ('tooltip' in props) checks — the union itself guards the branches.Stop Using `any` — A Type-Safe State Management Pattern
Junior devs love any. It feels faster. It is not. I just spent three hours on-call debugging a production crash because someone used any on a user object that changed shape between API versions. The type system is your first line of defense, not your enemy.
Here is the real reason any is dangerous: it disables all type checking downstream. One any pollutes your entire component tree. The fix is a discriminated union for your API response. Model every possible state — loading, success, error, empty. Then narrow with type guards. If your API returns a new field you didn't expect, TypeScript screams at compile time, not in production at 2 AM.
This pattern forces you to handle every state explicitly. No more if (data) { ... } and hoping. The match function ensures you return something for every branch. Your future self will thank you when the PM asks for a loading spinner.
data.email existed. After an API refactor, email became optional. Because we used any, no one caught it. Users got blank profile pages for three days before someone noticed the logs.any for API data. Model every state explicitly with a discriminated union.Why Your UseRef Is Breaking TypeScript — And How to Fix It
I've seen more production fires from useRef misuse than props. The pattern is always the same: a junior dev does const ref = useRef<HTMLInputElement>(null) and then tries to access ref.current.value without checking. TypeScript doesn't save them because current can be null. So they add a non-null assertion (!) and the bug disappears... until the component unmounts early.
Here is the fix: useRef with null as initial value means the ref is MutableRefObject<HTMLInputElement | null>. You must guard every access. But there is a smarter pattern — use a callback ref. It gives you the DOM node synchronously.
The callback ref pattern is safer because TypeScript narrows the type automatically inside the callback. No ?. operators everywhere. No runtime crashes from stale refs. This matters most in event handlers that fire after a component unmounts (like async data calls). Use this for form inputs, canvas elements, or any DOM node you manipulate directly.
ref.current! in a setTimeout callback. The component unmounts before the timeout fires. current is now null. Your app crashes with 'Cannot read properties of null'. Callback refs prevent this because they run synchronously.Polymorphic 'as' Prop Pattern with ComponentPropsWithoutRef
The polymorphic 'as' prop pattern allows a component to render as different HTML elements or custom components while maintaining type safety. This is essential for reusable UI primitives like buttons, headings, or containers. Using React's ComponentPropsWithoutRef ensures that the component accepts the correct props for the rendered element. Here's how to implement it:
- Define a generic
PolymorphicPropstype that extracts props from the element type. - Use
React.forwardRefto forward refs with correct typing. - Apply
ComponentPropsWithoutRefto get props excludingref.
Example: A Box component that can render as div, span, or section.
import React from 'react';
type BoxProps<T extends React.ElementType> = {
as?: T;
children?: React.ReactNode;
} & React.ComponentPropsWithoutRef<T>;
const Box = React.forwardRef(<T extends React.ElementType = 'div'>(
props: BoxProps<T>,
ref: React.ComponentPropsWithRef<T>['ref']
) => {
const { as: Component = 'div', ...rest } = props;
return <Component ref={ref} {...rest} />;
});
// Usage
<Box as="section" className="container">Content</Box>
<Box as="a" href="/link">Link</Box>
This pattern ensures that when you use as="a", the component expects href and other anchor-related props. Without ComponentPropsWithoutRef, TypeScript would not enforce these props. This is a production-grade pattern used in libraries like Chakra UI and Radix UI.
ComponentPropsWithoutRef enables type-safe rendering of different HTML elements while preserving prop validation.Compound Components with TypeScript: Typed Context + Namespaced Sub-Components
Compound components allow you to create a set of components that work together implicitly, like with . In TypeScript, you can achieve type safety by using a typed context and namespacing sub-components as static properties of the parent.
- Define a context with a generic type for shared state.
- Create the parent component that provides the context.
- Define sub-components that consume the context.
- Attach sub-components to the parent as static properties.
Example: A Tabs component with Tab and Panel sub-components.
```tsx import React, { createContext, useContext, useState } from 'react';
interface TabsContextType { activeIndex: number; setActiveIndex: (index: number) => void; }
const TabsContext = createContext
function useTabsContext() { const context = useContext(TabsContext); if (!context) throw new Error('Tabs sub-components must be used within
interface TabsProps { children: React.ReactNode; }
function Tabs({ children }: TabsProps) { const [activeIndex, setActiveIndex] = useState(0); return (
interface TabProps { index: number; children: React.ReactNode; }
function Tab({ index, children }: TabProps) { const { activeIndex, setActiveIndex } = useTabsContext(); return ( ); }
interface PanelProps { index: number; children: React.ReactNode; }
function Panel({ index, children }: PanelProps) { const { activeIndex } = useTabsContext(); return activeIndex === index ?
Tabs.Tab = Tab; Tabs.Panel = Panel;
export default Tabs; ```
Usage:
This pattern ensures that sub-components can only be used inside the parent, and TypeScript provides autocomplete for the static properties.
Typed Custom Hooks with Generics: useAsync Pattern
Custom hooks often need to handle asynchronous operations with different data types and arguments. Using generics, you can create a type-safe useAsync hook that infers the return type and argument types.
- Define a generic function type for the async operation.
- Use
useStateanduseEffectto manage loading, error, and data states. - Return typed state and a
runfunction.
Example: useAsync that takes an async function and returns its result.
```tsx import { useState, useCallback } from 'react';
interface AsyncState
type AsyncFn
function useAsync
const run = useCallback(async (...args: Args) => { setState({ data: null, loading: true, error: null }); try { const data = await asyncFn(...args); setState({ data, loading: false, error: null }); return data; } catch (error) { setState({ data: null, loading: false, error: error as Error }); throw error; } }, [asyncFn]);
return { ...state, run }; }
export default useAsync; ```
Usage: ``tsx const fetchUser = async (id: number): Promise<{ name: string }> => { const res = await fetch(/api/user/${id}`); return res.json(); };
const { data, loading, error, run } = useAsync(fetchUser); // data is inferred as { name: string } | null // run expects (id: number) => Promise<{ name: string }> ```
This pattern eliminates the need for manual type assertions and ensures that the hook's return types are always in sync with the async function.
useAsync<T, Args> provide end-to-end type safety for asynchronous operations, reducing runtime errors and improving developer experience.The Missing Generic Annotation That Took Down a Checkout Page
PaymentForm<T> received an array of mixed payment methods. TypeScript inferred T as the union of all possible values, not the specific method selected, causing the submit handler to receive an incorrectly typed object — and the render branch that handled the correct shape was never reached.<PaymentForm<CreditCard>> — or redesign the prop to use a discriminated union that forces TypeScript to narrow correctly.- Always specify the generic parameter explicitly when the inferred type might be wider than intended.
- Test generic components with every concrete type they'll receive in production — not just the happy path.
- Add a compile-time assertion (e.g.,
expect-type) to verify the generic resolves to the expected shape.
strict flag and ensure skipLibCheck is not masking errors. Run tsc --noEmit to verify build-time types match runtime.any type for a prop<T extends Record<string, unknown>>.React.forwardRef<HTMLDivElement, Props> and that the component function accepts ref as the second parameter.status) is a literal type, not a generic string. Use a mapped type to exhaustively cover all variants.tsc --noEmit --strictconsole.log type of prop using `typeof` in a testfunction MyComponent<T = SomeConcreteType>(props: T)| File | Command / Code | Purpose |
|---|---|---|
| io | interface Column | What is TypeScript with React? |
| io | type DataState | Discriminated Unions for Complex State Management |
| io | interface FormInputProps { | Forwarding Refs with Type Safety |
| io | const DataTable: React.FC | Performance Gotchas |
| io | type ButtonProps = { | Conditional Props and Type Narrowing with Generics |
| useApiState.ts | type ApiState | Stop Using `any` |
| SafeTextInput.tsx | interface SafeTextInputProps { | Why Your UseRef Is Breaking TypeScript |
| PolymorphicBox.tsx | type BoxProps | Polymorphic 'as' Prop Pattern with ComponentPropsWithoutRef |
| Tabs.tsx | interface TabsContextType { | Compound Components with TypeScript |
| useAsync.ts | interface AsyncState | Typed Custom Hooks with Generics |
Key takeaways
extends.unknown ref.React.FC for generic components; use plain generic functions instead.Interview Questions on This Topic
How would you type a generic table component that accepts a dynamic array of columns, where each column can render a function of the row data?
function DataTable<T>({ data, columns }: { data: T[]; columns: { header: string; render: (row: T) => ReactNode }[] }). The generic T is inferred from the data prop. Each column's render function receives a typed row. This pattern ensures type safety across all column renderers.Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.
That's TypeScript. Mark it forged?
6 min read · try the examples if you haven't