loke.dev
Header image for Generic Inference Is Too Helpful: How `NoInfer` Finally Rescues Your TypeScript API Design

Generic Inference Is Too Helpful: How `NoInfer` Finally Rescues Your TypeScript API Design

Stop allowing TypeScript to broaden your types across multiple function arguments and take control of your API’s type boundaries with the NoInfer utility.

· 5 min read

We’re told from day one that TypeScript’s greatest strength is its ability to "just figure it out." We praise the compiler for being smart enough to track types through nested callbacks and complex transformations without us having to lift a finger. But here is the truth: TypeScript’s inference is often too helpful, and that helpfulness is a direct threat to your API's integrity.

When you design a generic function, you often want one argument to define the "source of truth" and another to simply follow along. The problem? TypeScript is a people-pleaser. If the second argument doesn't match the first, TypeScript won't always complain. Instead, it will widen the generic type to accommodate both, effectively saying, "No worries, I'll just change the rules of the universe so you’re both right!"

The "Helpful" Compiler Problem

Let’s say you’re building a simple state-management function. You want to pass an initial value and a default "reset" value. Naturally, they should be the same type.

function createStore<T>(initialValue: T, defaultValue: T) {
  return { initialValue, defaultValue };
}

// This works as expected
const store = createStore("active", "inactive"); 

// This... also "works," which is the problem
const brokenStore = createStore("active", 123); 
// Resulting type: createStore<string | number>

In the example above, I clearly made a mistake by passing 123 as a default for a string-based store. I wanted TypeScript to scream at me. Instead, it looked at both arguments, shrugged, and decided that T is now string | number.

By trying to be helpful and find a common ground, the compiler just bypassed the very type safety I was trying to enforce.

The Old Way: Generic Gymnastics

Before TypeScript 5.4, we had to resort to some truly bizarre patterns to stop this behavior. The most common "hack" was to add a second generic type parameter that didn't actually do anything, or to use a conditional type to "mask" the inference.

It looked something like this:

type NoInferLegacy<T> = T extends any ? T : T;

function createStoreLegacy<T>(
  initialValue: T, 
  defaultValue: NoInferLegacy<T>
) {
  return { initialValue, defaultValue };
}

// Now this finally triggers an error!
const legacyStore = createStoreLegacy("active", 123); 
// Error: Argument of type 'number' is not assignable to parameter of type 'string'.

While it worked, it was incredibly unintuitive. Why does T extends any ? T : T change how the compiler works? It forces the compiler to defer evaluation, effectively telling it: "Don't try to guess T from this specific argument; wait until you know what T is from somewhere else."

It was a clever trick, but it felt like writing a magic spell rather than clean code.

Enter NoInfer: Taking Back Control

With the release of TypeScript 5.4, we finally got a first-class utility to solve this: NoInfer<T>.

NoInfer tells the compiler: "Use this position for type checking, but do not use it to help figure out what the generic type should be."

Here is our store example again, but written for the modern era:

function createStore<T>(initialValue: T, defaultValue: NoInfer<T>) {
  return { initialValue, defaultValue };
}

// Error: Argument of type 'number' is not assignable to parameter of type 'string'.
const modernStore = createStore("active", 123); 

By wrapping the second argument in NoInfer<T>, we've designated initialValue as the sole "source of truth" for the generic T. TypeScript finds that T is string from the first argument, and then strictly checks the second argument against that discovery.

A Practical Use Case: Event Emitters

This is particularly useful in event-driven architectures. Imagine you have a set of allowed event names, and you want to trigger a callback, but you also want to provide a "fallback" event name if the primary one fails.

type EventType = 'click' | 'hover' | 'scroll';

function handleEvent<T extends string>(
  primary: T, 
  fallback: NoInfer<T>
) {
  console.log(`Handling ${primary} or ${fallback}`);
}

// Valid
handleEvent('click', 'hover');

// Error: Argument of type '"focus"' is not assignable to parameter of type '"click"'.
// Because "focus" is not part of the inferred type from the first argument.
handleEvent('click', 'focus'); 

Without NoInfer, the second call would have just silently expanded the type of T to 'click' | 'focus', likely bypassing other checks further down your call stack.

Why Should You Care?

You might think, "I can just be careful with my types." But API design isn't about being careful; it's about making the wrong thing hard to do.

1. Refactoring Safety: When you change the type of a "source" argument, you want your IDE to immediately light up red everywhere the "dependent" arguments are now invalid.
2. Cleaner Error Messages: Instead of a complex error saying string | number isn't assignable to something else, you get a direct message saying number doesn't match string.
3. Intentional Design: Using NoInfer documents your intent. It tells other developers (and your future self) exactly which argument is intended to drive the type logic.

The Gotcha: Ordering Matters

One thing I've noticed is that NoInfer only works if there is *another* location where TypeScript can actually infer the type. If you wrap every single occurrence of T in NoInfer, TypeScript will give up and default T to unknown.

// Don't do this!
function badExample<T>(arg1: NoInfer<T>, arg2: NoInfer<T>) { 
  return [arg1, arg2]; 
}

const result = badExample("a", "b"); 
// result is unknown[] because TS had nowhere to infer T from.

Wrapping Up

TypeScript is a powerful tool, but sometimes it’s a bit too eager to please. NoInfer is the "stop" sign we’ve needed for years. It allows us to draw hard boundaries around our generics, ensuring that our APIs remain predictable and that the compiler works for us, not against us.

Next time you find yourself with a generic function where one argument should "lead" and the others should "follow," reach for NoInfer. Your future self, staring at a confusing union-type bug at 4:00 PM on a Friday, will thank you.