Enforcing string literal types in React components

Oct 28, 2024
snippet
typescript

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 Theme component changing its border color

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:

  1. When we parameterize U extends string, TypeScript treats each string literal as a distinct type rather than widening it to string.
  2. The type parameter T extends Step<U>[] creates the proper type relationship between the type of initialStep and steps.
  3. Finally, T[number]['name'] for initialStep extracts the union of literal types.