Skip to main content

TypeScript React Patterns and Best Practices: Advanced Tips

After learning TypeScript fundamentals, interfaces, and generics, you are ready to explore patterns that experienced React teams use to write scalable, maintainable code. Type guards, assertion techniques, and component composition patterns prevent entire categories of bugs while keeping code readable and testable. This article brings together advanced techniques that transform TypeScript from a safety net into a powerful design tool.

What Are Type Guards and How Do You Use Them?

A type guard is code that narrows a union type to a specific type. The most common guards check discriminant fields (like status), but you can also write custom type guard functions:

type Result<T> =
| { ok: true; data: T }
| { ok: false; error: string };

// Discriminant check (narrows the type)
function process<T>(result: Result<T>) {
if (result.ok) {
// TypeScript knows result.data exists here
console.log("Success:", result.data);
} else {
// TypeScript knows result.error exists here
console.log("Error:", result.error);
}
}

// Custom type guard function with 'is' keyword
function isString(value: unknown): value is string {
return typeof value === "string";
}

function handleValue(value: unknown) {
if (isString(value)) {
// TypeScript knows value is string
console.log(value.toUpperCase());
}
}

// Type guard for objects with specific shape
interface HasId {
id: number;
}

function hasId(obj: unknown): obj is HasId {
return (
typeof obj === "object" &&
obj !== null &&
"id" in obj &&
typeof (obj as any).id === "number"
);
}

Custom type guards with the is keyword are powerful for checking complex conditions. Use them to encapsulate type-checking logic and make your component code cleaner.

How Do You Use Type Assertions Safely?

A type assertion (using as) tells TypeScript to treat a value as a specific type. It bypasses type-checking, so use it sparingly and only when you are certain of the type:

// Asserting to a more specific type
const element = document.getElementById("app") as HTMLDivElement;
// Now TypeScript knows element is HTMLDivElement, not HTMLElement | null

// Asserting JSON response to a known shape
const data = (await response.json()) as User;

// Asserting Event to a specific event type
const handleClick = (e: React.SyntheticEvent) => {
const input = e.currentTarget as HTMLInputElement;
console.log(input.value);
};

// Using 'as const' to create literal types
const colors = ["red", "green", "blue"] as const;
// Type of colors is readonly ["red", "green", "blue"]
type Color = (typeof colors)[number]; // "red" | "green" | "blue"

Assertions are sometimes necessary, but they are risky—you bypass type safety. Always verify the actual type before asserting. Prefer type guards when possible.

How Do You Build Polymorphic Components?

Polymorphic components render different HTML elements while maintaining type safety. This is useful for components like buttons or links that might render as <button>, <a>, or a custom component:

import { ComponentPropsWithoutRef, ElementType, ReactNode } from 'react';

interface PolymorphicProps<T extends ElementType> {
as?: T;
children: ReactNode;
onClick?: () => void;
}

function Pressable<T extends ElementType = "button">({
as: Component = "button" as T,
children,
onClick,
}: PolymorphicProps<T> & ComponentPropsWithoutRef<T>) {
return (
<Component onClick={onClick}>
{children}
</Component>
);
}

// Renders as <button>
<Pressable onClick={() => {}}>Click me</Pressable>

// Renders as <a>
<Pressable as="a" href="/">Home</Pressable>

// Renders as custom component
<Pressable as={MyCustomButton}>Custom</Pressable>

Polymorphic components are advanced but powerful for building flexible, reusable components. The ComponentPropsWithoutRef<T> helper includes all props valid for the rendered element.

What Is the Override Pattern and When Should You Use It?

The override pattern extends a base type with additional properties. This is useful for creating variants of a component or for mixin patterns:

// Base button props
interface ButtonBaseProps {
children: ReactNode;
onClick: () => void;
}

// Variant 1: Primary button with accent color
interface PrimaryButtonProps extends ButtonBaseProps {
variant: "primary";
accentColor: "blue" | "green" | "purple";
}

// Variant 2: Danger button with confirmation
interface DangerButtonProps extends ButtonBaseProps {
variant: "danger";
confirmText: string;
}

// Union of variants
type ButtonProps = PrimaryButtonProps | DangerButtonProps;

function Button(props: ButtonProps) {
if (props.variant === "primary") {
return (
<button style={{ color: props.accentColor }}>
{props.children}
</button>
);
} else {
return (
<button title={props.confirmText}>
{props.children}
</button>
);
}
}

The override pattern with discriminated unions ensures each variant has exactly the props it needs, preventing mismatches.

How Do You Type Render Props and Compound Components?

Render props and compound components are advanced patterns for flexible component composition. Here is how to type them:

// Render props pattern
interface ListProps<T> {
items: T[];
render: (item: T, index: number) => ReactNode;
}

function List<T>({ items, render }: ListProps<T>) {
return <ul>{items.map((item, idx) => <li key={idx}>{render(item, idx)}</li>)}</ul>;
}

// Use with type inference
<List
items={users}
render={(user) => <span>{user.name}</span>}
/>

// Compound component pattern
interface FormProps {
children: ReactNode;
}

interface FormFieldProps {
name: string;
label: string;
children: (value: string, onChange: (v: string) => void) => ReactNode;
}

function Form({ children }: FormProps) {
const [values, setValues] = useState<Record<string, string>>({});

return <div>{children}</div>;
}

function FormField({ name, label, children }: FormFieldProps) {
// Manages its own state
const [value, setValue] = useState("");

return (
<label>
{label}
{children(value, setValue)}
</label>
);
}

// Use compound components
<Form>
<FormField name="email" label="Email">
{(value, onChange) => (
<input value={value} onChange={(e) => onChange(e.currentTarget.value)} />
)}
</FormField>
</Form>

Render props and compound components are patterns that give maximum flexibility. Type them carefully to ensure all children are valid.

How Do You Create a Safe Type-Safe Context?

React Context is powerful but often loses type safety. Here is a pattern to create type-safe context:

import { createContext, useContext, ReactNode } from 'react';

interface Theme {
primary: string;
secondary: string;
}

// Create context with a default (can be undefined)
const ThemeContext = createContext<Theme | undefined>(undefined);

// Create a custom hook that enforces usage within provider
function useTheme(): Theme {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}

interface ThemeProviderProps {
theme: Theme;
children: ReactNode;
}

function ThemeProvider({ theme, children }: ThemeProviderProps) {
return (
<ThemeContext.Provider value={theme}>
{children}
</ThemeContext.Provider>
);
}

// Component that uses the theme
function Button() {
const theme = useTheme(); // TypeScript knows theme is never undefined
return <button style={{ color: theme.primary }}>Click</button>;
}

// Usage
<ThemeProvider theme={{ primary: "blue", secondary: "gray" }}>
<Button /> {/* Works */}
</ThemeProvider>

// Outside provider, calling useTheme() throws an error

Type-safe context with a custom hook prevents the common mistake of using context outside its provider. The hook throws an error at runtime and provides type safety at compile time.

How Do You Handle Async and Promise Types?

Async operations are common in React. Here is how to type them correctly:

// Promise type for async functions
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/users/${id}`);
return response.json() as Promise<User>;
}

// Extracting the resolved type from a Promise
type UserPromise = Promise<User>;
type ResolvedUser = Awaited<UserPromise>; // Equals User

// In a custom hook
interface AsyncState<T> {
loading: boolean;
data: T | null;
error: Error | null;
}

function useAsync<T>(fn: () => Promise<T>, deps: unknown[]): AsyncState<T> {
const [state, setState] = useState<AsyncState<T>>({
loading: true,
data: null,
error: null,
});

useEffect(() => {
let cancelled = false;

fn()
.then((data) => {
if (!cancelled) {
setState({ loading: false, data, error: null });
}
})
.catch((error) => {
if (!cancelled) {
setState({ loading: false, data: null, error });
}
});

return () => {
cancelled = true;
};
}, deps);

return state;
}

Properly typing async code prevents race conditions and makes async state management explicit.

Key Takeaways

  • Type guards with the is keyword encapsulate type-checking logic and make unions safe to use.
  • Type assertions (as) bypass type-checking; use them sparingly and only when certain of the type.
  • Polymorphic components with ElementType and ComponentPropsWithoutRef render different elements safely.
  • Discriminated unions for variants ensure each variant has exactly the props it needs.
  • Type-safe context with a custom hook prevents usage errors and provides compile-time guarantees.
  • Properly type async operations to avoid race conditions and make async state explicit.

Frequently Asked Questions

How do I avoid the as any escape hatch?

as any disables type safety. If you are tempted to use it, stop and ask: Is the type wrong? Is the value actually that type? Use unknown instead of any and write a type guard to narrow it. The few minutes spent writing a proper type guard save hours of debugging.

// Wrong
const data: any = response.json();

// Right
const data = response.json();
// Verify it has the expected shape
if (isUser(data)) {
// Now TypeScript knows data is a User
}

What is the as const assertion?

as const creates literal types instead of broader types:

// Without as const
const status = "success"; // Type: string

// With as const
const status = "success" as const; // Type: "success" (literal)

// Useful for creating fixed value sets
const colors = ["red", "green", "blue"] as const;
type Color = (typeof colors)[number]; // "red" | "green" | "blue"

How do I type higher-order components (HOCs)?

HOCs are a more complex pattern. Here is a basic example:

function withLogging<P extends object>(
Component: React.ComponentType<P>
): React.ComponentType<P> {
return (props: P) => {
useEffect(() => {
console.log("Rendered");
}, []);
return <Component {...props} />;
};
}

In modern React, hooks are preferred over HOCs, but this pattern works when needed.

Should I use satisfies for type checking?

The satisfies operator (TypeScript 4.9+) checks that a value conforms to a type without changing the value's inferred type:

const user = { name: "Alice", age: 30 } satisfies User;
// user is still { name: string; age: number }, not User

This is useful for stricter checking while keeping type inference. Use it when you want both narrowness and flexibility.

Further Reading