November 17, 2021·6 min read·engineeringtypescriptpromisesdeclarationsimports

TypeScript 4.5 gives promise results a shared recursive type

TypeScript 4.5 introduces Awaited and improves Promise inference. The release also separates selected library declarations from the compiler package and makes value-import preservation explicit for framework builds.

A promise utility needs to describe the values observed after asynchronous inputs have resolved. Keeping a promise wrapper in the resulting element type can force callers to assert a relationship the runtime operation already establishes. TypeScript 4.5, released on November 17, introduces Awaited as a shared recursive description of that relationship. Its use in the standard declarations improves inference for operations such as Promise.all.

The release also addresses two other boundaries between declarations and executable code. A project can override selected bundled library declarations through packages, and framework builds can preserve imported values whose usage lies outside the script visible to TypeScript. These changes matter because the checker sees a model of the program that depends on the declarations and source supplied to it.

I find TypeScript most useful when that model follows the actual library and framework behavior closely. Its ecosystem can support sophisticated web interfaces, but inaccurate declarations or incomplete visibility can make valid operations awkward to express. The useful improvements here reduce those mismatches while making the remaining choices explicit.

Awaiting follows more than one wrapper

Awaited models recursive unwrapping. Applying it to Promise<string> produces string, while nested promise types continue through another application. A union of an ordinary value and a promise retains the ordinary branch and unwraps the asynchronous branch.

type Available = Awaited<string | Promise<number>>
// Available is string | number.

type Nested = Awaited<Promise<Promise<boolean>>>
// Nested is boolean.

The library definition shipped with the stable compiler inspects an object's then method structurally. It infers the value accepted by a callable fulfillment parameter and applies Awaited again. Null and undefined have a dedicated branch, and nonthenable types remain unchanged.

That structure matters because compatible completion behavior is not restricted to a type spelled as the built-in Promise. A utility that strips one Promise wrapper would describe a narrower operation. The standard alias gives declarations a common way to describe the recursive relationship instead of each library inventing its own shallow approximation.

This is static computation over a type. It does not wait for an operation, install rejection handling or change when an asynchronous callback runs. The resulting value type describes successful awaiting, while the program still needs the runtime failure behavior appropriate to the operation.

Promise.all maps each position to its awaited type

The stable promise declaration accepts a tuple-like input type and maps its positions through Awaited. It removes readonly from the mapped result. The return type is a promise containing that collection of resolved element types.

async function collect() {
  const inputs = [Promise.resolve(7), "ready"] as const
  const result = await Promise.all(inputs)
  const count: number = result[0]
  const status: "ready" = result[1]
  return { count, status }
}

The result preserves which value belongs to each input position. A broad array union would lose that relationship, while a shallow unwrapping could leave asynchronous types in a result that has already been awaited. The mapped declaration addresses both positional structure and resolved values.

The release announcement motivates the change with an API returning either a value, a promise or a PromiseLike. Earlier inference could retain promise types inside the returned tuple and reject a result annotation containing only numbers. The new declaration can describe the resolved result more accurately.

That improvement can still create compatibility work. The announcement identifies changed inference around explicit type arguments to promise utilities. Some calls no longer need their explicit arguments, while others need a single tuple-like argument describing the original inputs. Supplying already unwrapped element types can misdescribe an input that is possibly a promise.

This is a reason to inspect the relationship a type argument states. It can constrain the supplied values, not merely describe the output the programmer hopes to receive. The standard declaration now carries more of the transformation, so an old explicit annotation may be redundant or incorrect under that model.

Recursive conditional types have different intermediate costs

TypeScript 4.5 also performs tail-recursion elimination for some conditional types. A branch that directly returns another conditional type can avoid creating the same sequence of intermediate instantiations. The release uses a type that repeatedly removes a leading space from a string to explain the benefit.

The compiler still needs heuristics against excessive and potentially infinite instantiation. This optimization does not promise that every recursive type becomes inexpensive or accepts arbitrary depth. A recursive result that is then combined into a union requires a different evaluation pattern from a branch that returns the recursive result directly.

An accumulator parameter can sometimes express the latter form. The announcement demonstrates carrying the growing union as an argument while recursion proceeds. That changes the intermediate work required by the type evaluator without changing the intended final set of characters in its example.

I prefer this explanation to treating type-level computation as free. A library's declarations consume compiler resources while editors and builds evaluate them. Making useful recursive forms cheaper supports those interfaces, but their design still affects checking cost.

Bundled declarations can be versioned separately

Upgrading TypeScript ordinarily also upgrades its bundled declarations for standard JavaScript and browser APIs. A new DOM declaration can therefore change checking even when a project mainly wants a compiler improvement. The release introduces a package-based override for individual libraries.

When selecting a library such as dom, the compiler first looks for the corresponding scoped @typescript/lib-* package in node_modules. A project can use a package-manager alias to supply published web declarations under the expected package name. Its dependency lockfile can then retain that declaration version while the compiler changes.

This separates two upgrade decisions that were previously coupled. It does not make old declarations a claim about every browser's actual capabilities. The selected package still needs to represent the environment the application intends to use. The mechanism gives a project control over that selection instead of forcing it to arrive only through the compiler package.

The distinction is useful for diagnosing new errors as well. A changed compiler rule and a changed library declaration can both alter a build, but they have different causes. Independently versioning the declaration input makes the project's intended environment more explicit.

Framework-generated uses need preserved imports

TypeScript can remove an imported value when the script it sees does not use that value. The release announcement gives Svelte and Vue examples where markup outside the visible script generates a use later in the build. From TypeScript's local view the import appears unused, yet removing it makes the generated program incomplete.

The new preserveValueImports option retains those value imports. It is a statement about the compilation pipeline's visibility. Another tool may generate a real use that this compiler invocation cannot observe, so the apparent absence of a reference is insufficient reason to remove the binding.

Combined with isolatedModules, the option requires type imports to be explicitly marked. A tool that transforms one file at a time cannot always determine from that file whether an imported name denotes a runtime value or a type that must disappear. TypeScript 4.5 permits type modifiers on individual named imports to express the distinction without separate import statements.

import { render, type RenderOptions } from "./view.js"

Under the relevant preservation setting, render remains a value import while RenderOptions is erased. The source gives a single-file transform the information needed to make that decision. Preserving all imported names indiscriminately would instead risk retaining a type name with no runtime export.

A structured string can identify a union member

Template string types can now act as discriminants. The release demonstrates a union with tags ending in Success and Error. Comparing the tag with a concrete value such as HttpSuccess can narrow the object to the successful variant and permit access to that variant's body field.

This extends the relationship between a tag and its payload beyond a fixed list of literal tags. A family of tag names can share a suffix while identifying the same payload shape. The declaration still has to describe the relationship correctly. A comparison only gives useful narrowing because the variants' tag types distinguish them, so the feature does not make arbitrary strings evidence for an unrelated object shape.

The stable module scope differs from the beta

The stable announcement explicitly defers the new Node.js ECMAScript-module support described during the beta. The relevant Node-specific settings remain experimental and require nightly builds. They should not be presented as generally available capabilities of this stable release merely because earlier preview documentation discussed them.

That boundary is separate from the shipped promise and import features. Awaited improves the static description of existing asynchronous behavior, while explicit import roles help a framework pipeline preserve the runtime bindings it needs. The selected library packages define another input to checking, so compiler adoption can proceed with a deliberate choice of environment declarations.