June 20, 2024·6 min read·engineeringtypescripttypesdeclarationstooling

TypeScript 5.5 infers predicates while making exported types more explicit

TypeScript 5.5 removes predicate annotations from common filters and introduces isolated declaration checks. The two changes place inference where it helps callers while making publication boundaries easier for tools to process.

Type inference saves an author from repeating what a function body already establishes. It can also make generating a package's declarations depend on understanding code in other files. TypeScript 5.5, released June 20, addresses both sides of that tradeoff. It infers predicates from qualifying boolean functions and adds an optional check for exports whose declaration types cannot be obtained locally.

The features have different users. Predicate inference helps ordinary application code express a filter without a separate assertion about its meaning. Isolated declaration checks help package authors provide enough type information for simpler declaration-generation tools. The release consequently expands inference in one context while asking for explicit information at another boundary.

I regard that distinction as useful in TypeScript's ecosystem. Framework code benefits from inference that keeps routine transformations readable. Published packages also need interfaces that tools can describe without reconstructing an entire implementation. Treating those as separate design problems is more practical than insisting on either explicit annotations everywhere or inference everywhere.

A filter condition can supply its own predicate

The predicate feature connects a function's boolean result to a refinement on its parameter. A callback that tests whether an optional value is present can receive a predicate return type. The array filter declarations then use that predicate to describe the elements retained in the result. The runtime executes the existing JavaScript condition, while the inferred signature carries its consequence to the caller. An illustrative filter keeps valid zero values:

const measurements: (number | undefined)[] = [0, 4, undefined];
const available = measurements.filter(value => value !== undefined);

available.push(8);
available.push(undefined); // Type error with strictNullChecks

Before this improvement, an ordinary boolean callback could leave the resulting element type unchanged. A handwritten type predicate supplied the missing relationship. The new inference derives that relationship when the function has an appropriate single return, lacks an explicit return annotation, does not mutate its parameter, and returns a boolean tied to narrowing.

Those conditions keep the feature focused on functions the checker can analyze. A function with additional return paths can have more complicated implications. A parameter mutation can disconnect the returned condition from the input value the caller is trying to narrow. An explicit return annotation already states the signature the author chose. The compiler's inference operates within the remaining cases.

The false result must justify narrowing too

Dan Vanderkam's implementation account explains why checking only the true result is insufficient. A function combining a string test with an unrelated condition can return false for a string. Declaring it as a full string predicate would tell callers that false excludes strings, which the implementation does not establish. Predicate inference needs to respect both branches.

The release notes apply this reasoning to numeric truthiness. A callback using !!value on number | undefined rejects zero as well as the missing value. True implies a number, but false does not imply absence of a number. The compiler therefore declines the stronger predicate. An explicit comparison against undefined states the intended presence condition without discarding zero.

This is useful feedback about the program's data processing. A truthiness filter used before calculating an average can remove legitimate observations. Replacing it with a presence filter changes runtime behavior as well as type inference. The diagnostic can consequently expose an incorrect condition instead of asking for an annotation that conceals the ambiguity.

More precise inference can also reject an intentional later mutation. If a filter removes null, the result may now be inferred as an array excluding null. Pushing null into that variable becomes an error. When later insertion is intended, an explicit broader variable annotation describes that mutation policy. The initializer's narrower result and the variable's allowed future contents are separate facts.

Export inference can require following other modules

Declaration generation presents another use of inferred information. A .d.ts file needs the exported interface but does not include the implementation body. If an exported function returns the result of an imported function, determining its return type can require examining the imported declaration or implementation. Inference that is convenient inside a source file can therefore create work across the dependency graph during publication.

The release announcement explains this with a function combining imported values. Whether the result is a string or a number depends on the types of those values. A declaration emitter that does not run a full checker cannot assume the answer merely from seeing the + operator. It needs explicit information or access to the same inference machinery.

That is why quickly stripping types to produce JavaScript and generating declarations have different requirements. Removing an annotation can be a local syntactic operation. Constructing an annotation that was never written can require semantic analysis. A fast JavaScript transformer does not automatically have enough information to produce a correct package interface.

Isolated declarations check a condition for other tools

The new isolatedDeclarations option reports exports that cannot be reliably transformed without a checker. It requires declaration or composite. The announcement explicitly says it changes diagnostics rather than TypeScript's own emission behavior. Enabling it does not automatically introduce a faster or parallel build system. It identifies source suitable for tooling that can take advantage of the additional information. A small module can expose its return type while leaving local inference intact:

function internalLabel(id: number) {
  return "item-" + id;
}

export function publicLabel(id: number): string {
  const label = internalLabel(id);
  return label;
}

The exported return type is available without discovering the type of label through another function. Local variables can retain inferred types because they do not need to appear in the generated interface. This is a boundary requirement, not a demand to annotate every intermediate value. The release also permits certain expressions whose types are trivial to calculate locally.

The potential build benefit comes from reducing dependency order constraints. A project checking against another project's declarations normally needs those declarations first. If a simpler tool can generate interfaces independently, checking can potentially proceed with less waiting. That opportunity depends on external tooling and its orchestration, which remain distinct from the compiler option itself.

The stable release includes deliberate limits

Shared configuration receives another explicit boundary through ${configDir}. Relative paths in an inherited configuration ordinarily refer to the configuration file that declares them. That is appropriate when the shared file owns the referenced resource. It is less useful when the author intends each consuming project to have its own output directory with the same relative name.

The new template variable lets supported path settings refer to the consuming configuration's directory. A base file can therefore use an output path such as ${configDir}/dist without requiring every derived project to repeat it. This describes where the path belongs, instead of relying on readers to infer the author's intention from a shared relative string. It also avoids turning the base configuration's directory into an unintended common output location.

This distinction matters in the same multi-project builds that motivate declaration tooling. A project needs its own outputs and an interface consumed by other projects. Reusing configuration should not merge those output locations accidentally. The release's template variable provides a way to share the rule while keeping each project's filesystem location explicit. It does not change the type relationships between the projects, so it remains a configuration improvement separate from isolated declaration checks.

The announcement notes that isolated declarations do not support every pattern, including some computed property declarations. It recommends adoption according to the use case because additional export annotations have a cost. An application that does not publish declarations may receive less benefit than a package build designed around independent declaration generation. The option makes a particular tradeoff available rather than establishing a universal style rule.

Other stable-release changes refine the beta. Regular-expression checking becomes more lenient while retaining diagnostics for questionable escapes. New Set method declarations also arrive. These changes concern what the checker understands and accepts. They should not be confused with implementing those runtime methods in an older JavaScript engine.

The team also reverts a change that consulted package.json to determine module format more broadly. Feedback identified workflow disruption and excessive file-watching pressure in large projects. This is separate from consulting declared package dependencies to help name types during declaration generation, which remains described in the release. Similar references to package metadata can serve different compiler responsibilities.

I prefer the release's distinctions to a general promise of smarter inference. A presence predicate can now be derived from the condition that implements it. A public return type can be required where another tool needs a local declaration contract. The compiler also withdraws a broader metadata lookup when its operational costs are unsuitable. Each decision identifies which information is available, who consumes it, and what work is justified to obtain it.