February 28, 2022·6 min read·engineeringtypescriptnarrowinginference

TypeScript 4.6 follows correlated values through ordinary code

TypeScript 4.6 retains discriminated-union relationships after destructuring and improves mapped-type inference. Its changes distinguish useful correlations from assumptions the runtime does not support.

A type checker can know the possible types of two values and still lose the relationship between them. That is the problem behind several useful changes in TypeScript 4.6. An event tag can determine its payload, or a record can pair a value with a callback that accepts precisely that value's type. Checking those pieces independently loses information the program needs.

The February 28 release announcement makes these relationships a central part of the final compiler. It includes analysis for destructured discriminated unions and dependent parameters, along with better inference through indexed access into mapped types. The release does not require a new runtime construct for these cases. It improves what the checker can derive from existing declarations and JavaScript control flow.

I am cautious about TypeScript's complexity, but these changes address a practical source of unnecessary assertions. A useful type should describe the relationship that makes an operation valid. The checker should then follow that relationship through straightforward code where it can do so safely.

A union describes permitted combinations

Consider a result that either carries a count or a label. A union of complete objects can describe exactly those alternatives. Each member pairs its tag with the corresponding payload type.

type Result =
    | { kind: "count"; value: number }
    | { kind: "label"; value: string }

function format(result: Result) {
    const { kind, value } = result
    if (kind === "count") {
        return value.toFixed(0)
    }
    return value.trim()
}

The key information is the pairing. A separate union for each property would permit a count tag alongside a string value. The object union excludes that combination. Checking the tag therefore gives evidence about the value, not merely about the tag itself.

Before this analysis, destructuring could make the local variables appear independent. Keeping the object intact let the checker follow a discriminant property, but extracting the fields could lose the association. TypeScript 4.6 retains it for supported binding patterns, so local names no longer require giving up that evidence.

The implementation by Anders Hejlsberg limits the analysis to non-rest bindings declared as constants or parameters that remain unassigned. Those restrictions are part of the safety argument. Independently changing one variable could invalidate the relationship established by their common origin.

A constant binding does not make every object reachable through it deeply immutable. The useful fact here is narrower. The particular extracted tag and value remain the values selected together during destructuring. Treating that as a general immutability guarantee would claim more than the rule establishes.

Function signatures can encode the same dependency

Callbacks can receive correlated arguments without receiving an object. A rest parameter typed as a union of tuples expresses which argument combinations are allowed. TypeScript 4.6 follows that dependency when contextually typing the callback's parameters.

A signature containing one tuple for a successful result and another for a failed result can associate the corresponding data with each case. The checker can then narrow one parameter after examining the other. The relationship comes from the tuple alternatives in the signature.

That is stronger than giving both parameters unrelated union types. Independent unions describe the set of values each position may hold, but do not exclude invalid pairings between the positions. A tuple union describes combinations, so it can retain the condition that a particular first argument selects a particular second argument.

The dependent-parameter change also documents limits. A discriminant affects the directly containing type. Moving the tag into a nested object does not automatically give the checker the same relationship across other arguments. Type design still influences which facts control-flow analysis can derive.

This is useful guidance for event and callback APIs. A direct discriminator can make a relationship both readable and checkable. More elaborate structures may be justified, but they should not depend on the compiler discovering arbitrary semantic relationships that the type does not express directly.

Generic records need another representation

Control-flow narrowing is not the whole correlation problem. A generic operation may want to pass a record's value to that record's callback without branching on every tag. A simple union of callback-bearing objects can still lead the checker to treat the value and callable as separate unions.

The mapped-type and indexed-access implementation explains this failure. A value from some union member is not necessarily acceptable to a function from an independently selected member. The checker needs a representation showing that both selections use the same key.

The implementation demonstrates a mapping from tags to value types. A mapped type builds one complete record for each key, and an indexed access collects those records into a union. The generic key remains the common parameter connecting the value and callback.

type Values = { count: number; label: string }

type RecordFor<K extends keyof Values = keyof Values> = {
    [P in K]: {
        kind: P
        value: Values[P]
        consume: (value: Values[P]) => void
    }
}[K]

function deliver<K extends keyof Values>(record: RecordFor<K>) {
    record.consume(record.value)
}

The mapping is doing substantive work. It distributes complete records over keys, so each alternative retains one consistent relationship. Merely replacing every occurrence of a key with an unconstrained union would allow the components to vary independently again.

The release improves inference at call sites for this pattern. Its announcement shows a callback parameter acquiring the specific type associated with the supplied tag, where an older compiler inferred a wider union. That removes the need for assertions inside a callback whose correct argument type is already implied by the record.

I find this pattern useful when an API genuinely needs generic dispatch. It is more involved than an ordinary object union, so using it should follow from the operation's requirements. A handler that naturally branches on a tag can keep the simpler discriminated-union form.

More permissive analysis can accompany stricter errors

The release also changes object-rest typing for generic objects. Previously, removing a named property could leave a type that appeared to retain methods which object spread would not actually copy. TypeScript 4.6 drops members considered unspreadable, making the result better match the runtime operation.

This is a different kind of relationship. The checker must relate a result type to what JavaScript copies, rather than preserving every member visible on the original object's type. A class instance can expose methods through its prototype without those methods becoming members of the object produced by rest.

The announcement's example therefore becomes an error when it calls a method on the rest result. The stricter diagnostic corrects an assumption about runtime shape. It belongs alongside the new successful narrowing cases because both changes improve the correspondence between declarations and the operation being performed.

Constructor checking becomes more permissive for another reason. Earlier checks rejected code before super() in a derived constructor when the class had property initializers. The new check allows valid preceding code while retaining the requirement to call super() before referring to this.

These changes show why a compiler update can remove one diagnostic and introduce another without being inconsistent. One old check rejected valid JavaScript because it used a broad approximation. Another accepted a rest-result shape that did not match the copied properties. More precise analysis changes both outcomes.

None of these correlations supplies runtime validation for data crossing a network or file boundary. The checker starts from the types a program declares. A parser that constructs the union must still establish the tag and payload relationship it promises. Once that condition holds, retaining the relationship through local bindings and callbacks prevents needless loss of information. An assertion that invents the initial relationship would leave the same runtime uncertainty in place, even if every later branch compiles without a diagnostic.

Recursive comparison needs a stopping rule

The release's recursion-depth work addresses structural types that expand as their properties are compared. Comparing one property can require comparing deeper instantiations of the same generic relationship. The checker needs to recognize when this exploration is expanding indefinitely.

Older heuristics could confuse that situation with deeply nested types written explicitly by the programmer. TypeScript 4.6 distinguishes them. It can reject incompatible written nesting while concluding earlier that a genuinely expanding comparison has reached its stopping condition.

The announcement notes that the recursion change was also backported to 4.5.3. Its reported reductions in checking time for particular DefinitelyTyped packages should therefore not be presented as a universal gain from upgrading specifically to 4.6. The important mechanism is the distinction between declared depth and depth generated during comparison.

For application authors, the release provides several concrete ways to state relationships the checker can retain. A tag can select a payload after destructuring. A tuple alternative can connect callback arguments, and a mapped type can connect generic values and functions through the same key. The representation chosen by the API determines which evidence is available to the compiler at the operation that uses it.