Enforcing string literal types in React components
Leverage generics to create truly type-safe React components that prevent impossible states.
The Problem
Recently, I encountered this React component:
interface Step { name: string;}
interface Props { initialStep?: number; steps: Step[];}
function Wizard(props: Props): JSX.Element { return <></>;}
Coming from a functional programming background, I immediately spotted a critical issue: the component allows representation of illegal or impossible states. Specifically, we can pass an initialStep
value that’s outside the bounds of the steps
array:
<Wizard steps={[]} initialStep={Infinity} />;// ^^^^^^^^ This shouln't be allowed!
First attempt: “meh” solution
My first attempt at solving this involved introducing a generic type for the steps, allowing us to reference the type of initialStep
:
interface Step { name: string;}
interface Props<T extends Step> { initialStep?: T["name"]; steps: T[];}
function Wizard<T extends Step>(_: Props<T>): JSX.Element { return <></>;}
However, this approach falls short because initialStep
is still inferred as string
:
<Wizard steps={[{ name: "step-one" }, { name: "step-two" }]} initialStep="foo" // ^? initialStep?: string | undefined/>
We can force TypeScript to narrow the type by marking our steps array with as const
:
// ...interface Props<T extends Step> { initialStep?: T["name"]; steps: readonly T[];}// ...
<Wizard steps={[{ name: "step-one" }, { name: "step-two" }] as const} initialStep="step-one" // ^? initialStep?: "step-one" | "step-two" | undefined/>
While this works, it requires users to remember to add as const
when using the component. I wanted to enforce this type safety from within the component itself.
Second attempt: “the” solution
interface Step<T> { name: T;}
interface Props<T extends Step<string>[]> { initialStep?: T[number]["name"]; steps: T;}
function Wizard<U extends string, T extends Step<U>[]>(_: Props<T>): JSX.Element { return <></>;}
<Wizard steps={[{ name: "step-one" }, { name: "step-two" }]} initialStep="step-one" // ^? initialStep?: "step-one" | "step-two" | undefined // Using any other string value will result in an error too./>;
Now we achieve the desired behavior without as const
and get editor autocompletion as a bonus! 🎉

The trick here lies in how we parameterize the step name. Instead of using a generic that extends the entire Step interface, we make the name property itself generic. This approach fundamentally changes how TypeScript infers the types:
- When we parameterize
U extends string
, TypeScript treats each string literal as a distinct type rather than widening it to string. - The type parameter
T extends Step<U>[]
creates the proper type relationship between the type ofinitialStep
andsteps
. - Finally,
T[number]['name']
forinitialStep
extracts the union of literal types.