March 6, 2024·6 min read·engineeringtypescriptinferencetypescompiler

TypeScript 5.4 makes inference direction part of an API signature

NoInfer controls which arguments determine a generic type, while closure analysis preserves facts after a final assignment. Both changes improve how TypeScript represents relationships already present in JavaScript code.

A generic type parameter can describe agreement between arguments without specifying which argument defines the agreement. If both contribute inference candidates, the compiler may choose a type broad enough to accept a combination the API author intended to reject. TypeScript 5.4, released March 6, supplies NoInfer so a signature can make that direction explicit.

The release also preserves narrowing in more closures after a variable's final assignment. Both changes concern information the checker already encounters. One tells it which arguments should influence a type choice. The other lets it retain a fact when assignment order makes doing so safe. Their practical value is reducing the distance between a program's visible relationships and the compiler's account of them.

I find TypeScript most useful when those relationships remain understandable in ordinary source. Its ecosystem supports impressive web applications, but complicated type work can also make a component harder to explain. An inference marker that names a specific obligation is preferable to adding generic parameters solely to direct the checker. The resulting signature can state which input defines the allowed values.

Inferring a common type can accept the wrong default

Suppose an API receives a list of permitted statuses and an initial status. Both might be declared using a type parameter S extends string. If both arguments participate in inference, an unexpected initial status can contribute another string literal to S. The compiler then has a type under which all arguments are assignable, even though the initial value was absent from the list.

This behavior is a consequence of how the signature asks the compiler to infer. It does not mean the compiler has established membership in the runtime array. The intended rule uses one argument to define an allowed type and another to consume it. NoInfer prevents the consuming argument from participating in selection, leaving it subject to the type inferred elsewhere. An illustrative implementation makes that distinction visible:

function initialStatus<S extends string>(
  allowed: S[],
  initial: NoInfer<S>
): S {
  return initial;
}

initialStatus(["queued", "running"], "queued");
initialStatus(["queued", "running"], "finished"); // Type error

The function returns its initial argument, so the example isolates the type-level relationship. Its first argument contributes candidates for S. The second call fails because its initial literal cannot expand that candidate set. JavaScript execution still requires a separate runtime validation policy if values arrive from untyped external data. A type signature does not inspect an arbitrary request body on the application's behalf.

There is also a limit in the type information supplied by the caller. A list already widened to string[] communicates that its elements can be any string. Excluding the other argument from inference does not recover literal information the list's type no longer contains. The API can only check the relationship expressed by the available types. That is different from proving membership in a particular array's current contents.

NoInfer changes inference without introducing a new value type

The implementation pull request describes NoInfer<T> as a marker that otherwise behaves identically to T. The implementation uses an existing substitution-type representation with an unknown constraint. That design reuses compiler machinery for erasing markers and avoids introducing a new type category for surrounding tools to understand.

This matters when interpreting the name. NoInfer does not mean that the argument has no type or escapes assignability checks. It excludes a location from inference into the contained type. Once the compiler has determined the type, ordinary checking still applies. The implementation examples also place the marker inside object and array types, showing that it can describe inference boundaries within a larger argument shape.

Before this utility, the release notes describe adding a second parameter constrained by the first, such as D extends S. That can express a suitable constraint but introduces a parameter that may appear only once. The marker provides a direct account of why the argument should be checked without helping select the type. This is useful in reusable APIs where one property defines choices and another supplies a selection.

A final assignment gives a closure a stronger fact

The closure-refinement implementation explains the other major checking improvement. Previously, captured constants and never-assigned parameters could preserve refinements. The change extends preservation to certain mutable variables when the closure is created after their final assignment. The compiler can then use the narrowed type known at that point. For example, a function may normalize an optional argument before returning a callback:

function labeler(prefix: string | undefined) {
  if (prefix === undefined) {
    prefix = "item";
  }

  return (name: string) => prefix.toUpperCase() + " " + name;
}

Creation of the arrow function follows the assignment establishing a string. There is no subsequent assignment to reopen the possibility of undefined. That order lets the callback retain the refinement. It does not depend on knowing the callback's eventual invocation time, which could occur after the outer function has returned.

A later assignment would change the argument. A nested function that assigns the captured variable also blocks this analysis because its timing cannot generally be established. Even a self-assignment can invalidate the refinement under the documented rule. The checker recognizes a particular safe pattern without attempting to prove the scheduling behavior of every function that shares the variable.

The implementation also limits refinement around compound statements. Assignment effects extend to the statement as a whole, so a closure after a completed conditional may receive information unavailable to one inside a branch. This is a deliberate compromise in analysis cost. It explains why the feature improves common normalization patterns without accepting every program whose safety a human might establish through deeper reasoning.

Module checking also needs the direction of execution

TypeScript 5.4's new module: preserve setting addresses a related need for accurate relationships. The module implementation proposal explains that bundler resolution previously assumed import-based lookup and therefore required an emission mode that retained imports. Supporting require needed a way to preserve the distinct operation in the source.

The new setting keeps ECMAScript module syntax in its module form while allowing TypeScript's CommonJS import syntax to become a require call. Conditional package exports can then follow the import or require condition corresponding to that operation. This still matters with noEmit, since the checker needs to resolve the declaration associated with the operation another tool will execute.

The common issue is information loss. If the checker treats every module reference as an import, it can select the wrong export condition. If generic inference treats every argument as an equal source, it can miss an intended constraint. The corrections preserve distinctions already meaningful to the program or API, giving the checker a more accurate basis for its answer.

Better information can make a result more cautious

The release adds declarations for Object.groupBy and Map.groupBy. The object result uses optional properties because a general input does not guarantee every possible group will be produced. A key-producing callback describes possible keys, while the actual input determines whether each appears. The types preserve that uncertainty instead of claiming a complete result merely because the key union is known.

This is a useful counterpoint to viewing every checker improvement as accepting more code. Sometimes a better model retains uncertainty and requires the caller to account for it. The release also tightens enum compatibility when known member values disagree. Those diagnostics arise because the declarations contain information that a permissive earlier rule did not fully respect.

Import attributes receive a similar connection to explicit declarations. TypeScript 5.4 checks them against the global ImportAttributes type. A runtime's declarations can therefore restrict an attribute value, allowing a mismatched import to produce a diagnostic. The compiler has no reason to assume that every possible attribute spelling has the same meaning to the runtime. Supplying a type for the accepted attributes gives the checker a concrete basis for comparison.

This also separates declaration support from implementing a JavaScript facility. Knowing the type of Object.groupBy does not install that method in the runtime. Selecting library declarations establishes what the checker understands, while the execution environment determines which operations actually exist. The release notes require appropriate target or library settings for the new grouping declarations. A successful type check consequently describes compatibility with the configured model, including the runtime capabilities that model assumes.

I judge these changes by whether their consequences can be explained locally. A default argument should conform to the choices supplied elsewhere. Creating a callback after normalization means it can retain the resulting type when no mutation invalidates it. Grouped objects may omit a group that received no values. TypeScript 5.4 gives each relationship a more specific representation, including cases where that representation correctly produces a new error.