TypeScript 3.9 RC keeps non-null assertions inside optional chains
The 3.9 release candidate changes how optional chaining combines with a non-null assertion. An assertion about types no longer inserts an unintended runtime boundary in the chain.
An assertion intended to affect types can accidentally change where JavaScript stops evaluating an optional chain. The TypeScript 3.9 release candidate announced April 28 changes the interpretation of foo?.bar!.baz. The non-null assertion no longer ends the chain in the way older TypeScript versions treated it. If foo is undefined, the chain can return undefined without attempting the final property access.
This is a change to runtime behaviour after compilation, so it deserves more attention than a newly accepted or rejected annotation. The announcement attributes the problem to feedback from the Babel team and other users. Their expectation was that ! should disappear while preserving the optional chain around it.
I value TypeScript when its types clarify the JavaScript that a framework application runs. An erased assertion should be particularly careful about preserving that relationship. Otherwise a declaration-level operation can introduce an execution boundary that is difficult to see in review.
The old interpretation ended short-circuiting
The release-candidate announcement compares the previous interpretation with (foo?.bar).baz. Parentheses end the optional chain before the final access. When foo is undefined, the parenthesized expression evaluates to undefined and the subsequent .baz access throws.
That explains the surprising effect of placing ! between the property accesses. A programmer can intend to assert that bar is not nullable when foo exists. The old interpretation also changes what happens when foo does not exist at all. Those are different conditions.
The new interpretation corresponds to foo?.bar.baz. The initial optional access can short-circuit the continuing chain when foo is absent. It does not make every later value independently optional. If foo exists but bar is undefined, a normal access to baz through that value can still fail.
The distinction can be expressed with an example that covers the missing outer object. The assertion checks the resulting value, not just whether the compiler accepts the source.
interface Container {
child?: { name: string };
}
function readName(value: Container | undefined): string | undefined {
return value?.child!.name;
}
console.assert(readName(undefined) === undefined);
console.assert(readName({ child: { name: "worker" } }) === "worker");
The ! is a claim about the child value, not a runtime check that manufactures a child. Supplying an existing container with no child would violate that claim. The example deliberately separates that condition from supplying no container.
Explicit grouping retains the old boundary
The announcement provides a way to request the previous behaviour explicitly. Parentheses around the optional access make the boundary part of the expression rather than an accidental consequence of the assertion.
function readRequiredName(value: Container | undefined): string {
return (value?.child)!.name;
}
This version does not promise a useful result for an absent container. Its grouped expression can produce undefined, and the final property access occurs outside the optional chain. The non-null assertion permits the source-level access but does not prevent a runtime failure.
For code depending on that grouping, preserving the parentheses preserves the intent described in the migration note. For code expecting the chain to short-circuit, the release candidate supplies the new interpretation directly. The difference is observable even though both forms contain a non-null assertion.
The preview gives the change a defined test target
The March beta announcement had already described this breaking change. The release-candidate announcement now says the team expects only critical bug fixes before the final release. That gives users a more stable preview against which to assess emitted behaviour.
It does not make the release candidate the final release. The significance is that the changed interpretation is present and documented, so an application can examine its actual chain expressions against a specific compiler version. The expected outcome should distinguish an absent initial object from a later property that violates an assertion.
I prefer that distinction to treating every nullable access as one generic failure case. Optional chaining expresses where absence should stop evaluation. A non-null assertion expresses a separate belief about a type. The generated program needs to preserve both meanings without silently introducing another grouping decision.
The candidate documents another emitted-code correction in class accessors. Earlier output could make getters and setters enumerable when targeting ES5, differing from the behaviour of ES2015 classes. The corrected output makes them non-enumerable. Like the chaining change, that correction can affect the running program without requiring a new public type declaration. It shows why evaluating a compiler update only through its diagnostic count misses some compatibility questions. The relevant evidence includes the operations that remain after TypeScript syntax has been removed.
Diagnostics and execution need separate evidence
The candidate also carries @ts-expect-error and the performance work described during the beta. Checked suppression can establish that a line produces a diagnostic, but it cannot establish the runtime meaning of a chain after emit. A compiler acceptance test and a value assertion answer different questions here.
For the changed expression, the relevant execution boundary is concrete. value?.child!.name keeps the optional chain active, while (value?.child)!.name makes the final property access unconditional after the grouped result. The source can express either policy explicitly under the release candidate.