loke.dev
Header image for Why Does Your 'Strict' Generic Function Still Widen Your TypeScript Literals?

Why Does Your 'Strict' Generic Function Still Widen Your TypeScript Literals?

Discover how the 'const' type parameter modifier finally eliminates the friction of manual 'as const' assertions in your generic API designs.

· 4 min read

Why Does Your 'Strict' Generic Function Still Widen Your TypeScript Literals?

You probably think your generic functions are capturing every detail of the arguments you pass into them. You’ve spent time carefully defining <T extends string>, expecting that if you pass the string "admin", TypeScript will treat T as the literal type "admin".

But here’s the cold truth: TypeScript is a bit of a minimalist. By default, it prefers to "widen" your literals. It sees your specific "admin" and thinks, *"You probably just mean a generic string that happens to be 'admin' right now."* It’s not a bug; it’s the compiler trying to be helpful by assuming you might want to mutate that value later.

Unfortunately, when you’re building high-end type-safe APIs, this "helpfulness" feels a lot like a betrayal.

The Frustration of Literal Widening

Let’s look at a common scenario. You’re building a simple navigation helper. You want the function to know exactly which routes were defined so it can provide autocomplete later.

function defineRoutes<T extends string>(routes: T[]) {
  return routes;
}

// You expect the type to be ["home" | "dashboard" | "settings"]
const myRoutes = defineRoutes(["home", "dashboard", "settings"]);

// Instead, myRoutes is just string[]
// No autocomplete, no errors if we pass "profile" later.

In the example above, TypeScript sees ["home", "dashboard", "settings"] and decides the most useful type for T is string. This happens because TypeScript assumes the array might be modified later. It chooses the "widest" possible type that satisfies the constraint.

The Old Way: The as const Tax

Before TypeScript 5.0, we had to force the compiler's hand. We’d tell the *caller* of the function to use a const assertion.

const myRoutes = defineRoutes(["home", "dashboard", "settings"] as const);

This works, but it sucks for DX (Developer Experience). You’re essentially asking the users of your library to do the heavy lifting. It's like inviting someone to a dinner party but asking them to bring their own silverware, plate, and oven. If they forget as const, your fancy type logic falls apart.

Enter the const Type Parameter

TypeScript 5.0 introduced a feature that finally puts the power back into the hands of the API author: the const modifier for type parameters. By adding const before your generic name, you’re telling TypeScript: *"Treat whatever is passed here as if it had an as const assertion attached to it."*

Let’s fix our defineRoutes function:

// Look at that beautiful 'const' right there
function defineRoutes<const T extends string>(routes: T[]) {
  return routes;
}

const myRoutes = defineRoutes(["home", "dashboard", "settings"]);
// Type is now readonly ["home", "dashboard", "settings"]

Now, myRoutes isn't just a string[]. It’s a specific, read-only tuple of literal types. You didn't have to change how you called the function; you just changed how the function was defined.

Why This Matters for Configuration Objects

The real magic happens when you’re dealing with nested objects. Without the const modifier, TypeScript widens every property in an object.

Imagine you're building a component library where users define themes:

interface ThemeConfig {
  colors: Record<string, string>;
  spacing: Record<string, number>;
}

function createTheme<const T extends ThemeConfig>(config: T) {
  return config;
}

const theme = createTheme({
  colors: {
    primary: "#007bff",
    secondary: "#6c757d"
  },
  spacing: {
    small: 8,
    medium: 16
  }
});

// theme.colors.primary is now type "#007bff" (the literal)
// instead of just "string".

Because of the const modifier, TypeScript keeps the exact hex codes as types. This allows you to do some incredibly powerful things downstream, like generating CSS variables or ensuring that a Button component only accepts valid keys from your colors object.

The Fine Print (Gotchas)

As much as I love this feature, it isn't a magic wand you should wave at every generic.

1. It Defaults to Readonly

When you use a const type parameter, TypeScript infers the type as readonly. This makes sense—if it's a literal, you shouldn't be changing it—but it can cause issues if your function expects to mutate the input (which, let's be honest, you probably shouldn't be doing anyway in a clean API).

2. It Doesn't Work on Everything

const modifiers only affect the inference of object, array, and primitive literals. If you pass a variable that has already been defined and widened, the const modifier can't "narrow" it back down.

const widenedColor = "#007bff"; // Type is string
const myTheme = createTheme({ colors: { primary: widenedColor }, spacing: {} });
// primary is still string, because widenedColor was already widened!

3. Class Compatibility

Avoid using const modifiers on generics that are intended to be used with class instances. Classes have their own complex ways of handling inference, and const modifiers are generally designed for "plain old data" structures.

Summary

The const type parameter modifier is one of those small changes that has a massive impact on the quality of your TypeScript libraries. It removes the friction of manual assertions and ensures that your "strict" functions actually stay strict.

If you’re building an API that relies on literal types—whether it's for routing, state management, or styling—stop asking your users to write as const. Add the const modifier to your generics and let the compiler do the work for you.

Your users (and your future self) will thank you.