Moving Beyond "Any"
1. Discriminated Unions for Complex State
Typescript
// ❌ Bad: Impossible states allow for bugs
type BadFetchState = {
isLoading: boolean;
isError: boolean;
data?: UserData;
errorMessage?: string;
};
// ✅ Good: Discriminated Unions
type FetchState =
| { status: 'loading' }
| { status: 'success'; data: UserData }
| { status: 'error'; error: Error };
function UserProfile({ state }: { state: FetchState }) {
if (state.status === 'loading') {
return <Spinner />;
}
if (state.status === 'error') {
// TypeScript knows `state.error` exists here
return <Error msg={state.error.message} />;
}
// TypeScript guarantees `state.data` exists here. No optional chaining needed!
return <div>{state.data.name}</div>;
}
2. ComponentProps Without Boilerplate
Typescript
import { ComponentProps } from 'react';
// Extract all native button props
type NativeButtonProps = ComponentProps<'button'>;
// Add our custom props
interface CustomButtonProps extends NativeButtonProps {
variant?: 'primary' | 'secondary';
isLoading?: boolean;
}
export function Button({ variant = 'primary', isLoading, children, ...rest }: CustomButtonProps) {
return (
<button
className={`btn-${variant}`}
disabled={isLoading || rest.disabled}
{...rest} // Spreads onClick, aria-label, etc.
>
{isLoading ? 'Loading...' : children}
</button>
);
}
3. Polymorphic Components (The as Prop)
Typescript
import { ElementType, ComponentPropsWithoutRef } from 'react';
type TextProps<T extends ElementType> = {
as?: T;
color?: 'primary' | 'secondary';
} & ComponentPropsWithoutRef<T>;
// Using a generic <T> to track the element type
export function Text<T extends ElementType = 'span'>({
as,
color = 'primary',
children,
...rest
}: TextProps<T>) {
const Component = as || 'span';
return (
<Component className={`text-${color}`} {...rest}>
{children}
</Component>
);
}
// Usage:
// Valid: 'href' is allowed because as="a"
<Text as="a" href="https://example.com">Link</Text>
// Invalid: TypeScript throws an error because 'href' is not valid on a 'span'
<Text color="secondary" href="...">Text</Text>
4. Generic Components for Reusable, Type-Safe Lists
Typescript
type ListProps<T> = {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyExtractor: (item: T) => string;
};
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={keyExtractor(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
// Usage — TypeScript infers T as User automatically from the `items` array
<List
items={users}
keyExtractor={(user) => user.id}
renderItem={(user) => <span>{user.name}</span>} // `user` is fully typed as User
/>
5. satisfies for Configuration Objects
Typescript
type RouteConfig = {
path: string;
requiresAuth: boolean;
};
// Without satisfies: the object is widened to RouteConfig,
// losing the literal type of `path`
const badRoute: RouteConfig = { path: '/dashboard', requiresAuth: true };
// With satisfies: TypeScript still checks the shape against RouteConfig,
// but `path` keeps its literal type '/dashboard' for autocomplete elsewhere
const goodRoute = { path: '/dashboard', requiresAuth: true } satisfies RouteConfig;
Putting These Patterns Together
Typescript
type IconButtonProps = {
variant: 'icon';
icon: ReactNode;
'aria-label': string; // required — icon-only buttons must be labeled
};
type TextButtonProps = {
variant: 'text';
children: ReactNode;
};
type ButtonProps = (IconButtonProps | TextButtonProps) & ComponentProps<'button'>;
Why This Matters for Team-Scale Codebases
- Fewer runtime bugs from impossible states, since the type system rules them out at the source.
- Self-documenting components. A well-typed prop union tells a new engineer what's valid faster than a README ever could.
- Safer refactors. Renaming a variant or adding a new required field surfaces every affected call site as a compiler error, not a bug report three sprints later.
- Better autocomplete and IDE support, which compounds across a large component library used by many teams.