loke.dev
Header image for 3 Practical Ways Template Literal Types Can Save Your React Props From String-Literal Hell

3 Practical Ways Template Literal Types Can Save Your React Props From String-Literal Hell

Stop letting typos in your CSS utility classes or route parameters break your UI by leveraging TypeScript's most powerful string-manipulation feature.

· 3 min read

How many times have you stared at a broken UI component only to realize you typed blue-50 instead of blue-500 in a prop?

It’s the kind of bug that makes you want to gently close your laptop and go for a very long walk in the woods. We’ve all been there. We try to make our React components flexible by accepting string props, but string is a dangerously wide net. It's the "any" of text data.

Template Literal Types, introduced in TypeScript 4.1, are the antidote. They allow us to define string patterns that the compiler actually understands. Here are three ways I’ve used them to kill off "string-literal hell" in my own React projects.

1. The Design System "Color + Shade" Combo

If you’re building a button or an icon component, you probably want to restrict colors to your brand palette. Usually, people do something like this:

type ButtonColor = 'red-100' | 'red-200' | 'blue-100' | 'blue-200' // ...and so on forever

This is fine until you have 10 colors and 9 shades each. Now you’re maintaining a list of 90 strings. If you miss one, the component breaks. If you add a color, you have to manually update the union.

Instead, let the compiler do the math:

type Color = 'primary' | 'secondary' | 'accent' | 'gray';
type Shade = 50 | 100 | 200 | 300 | 400 | 500;

type DesignToken = `${Color}-${Shade}`;

interface ButtonProps {
  color: DesignToken;
  label: string;
}

// ✅ Works perfectly
const GreenButton = <Button color="primary-500" label="Submit" />;

// ❌ TypeScript screams: "primary-1000" is not assignable to DesignToken
const SadButton = <Button color="primary-1000" label="Error" />;

The beauty here is that you get full auto-complete. When you type color="", VS Code will suggest every single valid combination. It feels like magic, but it’s just basic set theory.

2. Type-Safe Internal Routing

If you’ve ever navigated a user to /dashboard/setings (notice the missing 't'?) and wondered why they hit a 404, this one is for you.

When we pass paths as props to a <Link /> or a navigate() function, we often just use string. We can do better. We can tell TypeScript that a string *must* follow a specific format, like starting with a slash or belonging to a specific domain.

type StaticRoutes = '/' | '/about' | '/contact' | '/pricing';
type DynamicRoutes = `/user/${string}` | `/post/${number}`;

type AppPath = StaticRoutes | DynamicRoutes;

function SmartLink({ to, children }: { to: AppPath; children: React.ReactNode }) {
  return <a href={to}>{children}</a>;
}

// ✅ Valid
<SmartLink to="/user/jdoe123">Profile</SmartLink>

// ✅ Valid
<SmartLink to="/post/42">Read More</SmartLink>

// ❌ Error: Property 'dashboard' does not exist (forgot the leading slash)
<SmartLink to="dashboard">Home</SmartLink>

By using ${string} or ${number} inside the template literal, you’re creating a "pattern" rather than a fixed set of values. It’s a great middle ground between "anything goes" and "strictly enumerated list."

3. Organizing CSS Utility Props (The "Shorthand" API)

Sometimes we want our components to handle spacing props like Tailwind does (e.g., mx-4, pt-2). Writing a manual union for every possible margin and padding direction is a nightmare.

I recently used this to build a Box layout component that needed to be very strict about its spacing API to prevent "designers-crying-at-night" syndrome.

type Direction = 't' | 'b' | 'l' | 'r' | 'x' | 'y';
type SpacingValue = 0 | 2 | 4 | 8 | 12 | 16 | 20;

// This generates things like 'mt-4', 'px-0', 'rb-20', etc.
type PaddingProp = `p${Direction}-${SpacingValue}`;
type MarginProp = `m${Direction}-${SpacingValue}`;

interface BoxProps {
  spacing?: PaddingProp | MarginProp;
  children: React.ReactNode;
}

const Container = ({ spacing, children }: BoxProps) => {
  return <div className={spacing}>{children}</div>;
};

// ✅ Good to go
<Container spacing="px-8">Hello World</Container>;

// ❌ Error: 'mt-7' is not a valid spacing value
<Container spacing="mt-7">Invalid Spacing</Container>;

A Quick Word of Caution

Before you go off and create a template literal that generates 10,000 permutations: TypeScript does have a limit. If your union results in too many combinations (usually around 100,000), the compiler will throw a "union type that is too complex to represent" error and essentially give up on life.

Keep your sets focused. You don't need to represent every possible string in the universe—just the ones that make your design system work.

Template literal types aren't just for showing off; they turn "I think this works" into "I know this works." And in a world of complex React state and shifting requirements, that's a massive win for your sanity.