January 29th, 2025

Announcing TypeScript 5.8 Beta

Daniel Rosenwasser
Principal Product Manager

Today we are excited to announce the availability of TypeScript 5.8 Beta.

To get started using the beta, you can get it through npm with the following command:

npm install -D typescript@beta

Let’s take a look at what’s new in TypeScript 5.8!

Checked Returns for Conditional and Indexed Access Types

Consider an API that presents a set of options to a user:

/**
 * @param prompt The text to show to a user.
 * @param selectionKind Whether a user can select multiple options, or just a single option.
 * @param items Each of the options presented to the user.
 **/
async function showQuickPick(
    prompt: string,
    selectionKind: SelectionKind,
    items: readonly string[],
): Promise<string | string[]> {
    // ...
}

enum SelectionKind {
    Single,
    Multiple,
}

The intent with showQuickPick is that it shows a UI element that can allow selecting either a single option or multiple options. When it does this is determined by the selectionKind parameter. When selectionKind is SelectionKind.Single, the return type of showQuickPick should be string, and when it is SelectionKind.Multiple, the return type should be string[].

The problem is that the type signature of showQuickPick doesn’t make this clear. It just says that it eventually returns string | string[] – it could be a string and it could be a string[], but callers have to explicitly check. In our example below, we might expect shoppingList to have the type string[], but we end up with the more broad string | string[].

let shoppingList = await showQuickPick(
    "Which fruits do you want to purchase?",
    SelectionKind.Multiple,
    ["apples", "oranges", "bananas", "durian"],
);

console.log(`Alright, going out to buy some ${shoppingList.join(", ")}`);
//                                                         ~~~~
// error!
// Property 'join' does not exist on type 'string | string[]'.
//  Property 'join' does not exist on type 'string'.

Instead, we can use a conditional type to make the return type of showQuickPick more precise:

type QuickPickReturn<S extends SelectionKind> =
    S extends SelectionKind.Multiple ? string[] : string

async function showQuickPick<S extends SelectionKind>(
    prompt: string,
    selectionKind: S,
    items: readonly string[],
): Promise<QuickPickReturn<S>> {
    // ...
}

This works well for callers!

// `SelectionKind.Multiple` gives a `string[]` - works ✅
let shoppingList: string[] = await showQuickPick(
    "Which fruits do you want to purchase?",
    SelectionKind.Multiple,
    ["apples", "oranges", "bananas", "durian"],
);

// `SelectionKind.Single` gives a `string` - works ✅
let dinner: string = await showQuickPick(
    "What's for dinner tonight?",
    SelectionKind.Single,
    ["sushi", "pasta", "tacos", "ugh I'm too hungry to think, whatever you want"],
);

But what if we try to actually implement showQuickPick?

async function showQuickPick<S extends SelectionKind>(
    prompt: string,
    selectionKind: S,
    items: readonly string[],
): Promise<QuickPickReturn<S>> {
    if (items.length < 1) {
        throw new Error("At least one item must be provided.");
    }
    
    // Create buttons for every option.
    let buttons = items.map(item => ({
        selected: false,
        text: item,
    }));

    // Default to the first element if necessary.
    if (selectionKind === SelectionKind.Single) {
        buttons[0].selected = true;
    }

    // Event handling code goes here...

    // Figure out the selected items
    const selectedItems = buttons
        .filter(button => button.selected)
        .map(button => button.text);

    if (selectionKind === SelectionKind.Single) {
        // Pick the first (only) selected item.
        return selectedItems[0];
    }
    else {
        // Return all selected items.
        return selectedItems;
    }
}

Unfortunately, TypeScript issues an error on each of the return statements.

Type 'string[]' is not assignable to type 'QuickPickReturn<S>'.
Type 'string' is not assignable to type 'QuickPickReturn<S>'.

Until this point, TypeScript required a type assertion to implement any function returning a higher-order conditional type.

      if (selectionKind === SelectionKind.Single) {
          // Pick the first (only) selected item.
-         return selectedItems[0];
+         return selectedItems[0] as QuickPickReturn<S>;
      }
      else {
          // Return all selected items.
-         return selectedItems;
+         return selectedItems as QuickPickReturn<S>;
      }

This is not ideal because type assertions defeat legitimate checks that TypeScript would otherwise perform. For example, it would be ideal if TypeScript could catch the following bug where we mixed up each branch of the if/else:

    if (selectionKind === SelectionKind.Single) {
        // Oops! Returning an array when the caller expects a single item!
        return selectedItems;
    }
    else {
        // Oops! Returning a single item when the caller expects an array! 
        return selectedItems[0];
    }

To avoid type assertions, TypeScript 5.8 now supports a limited form of checking against conditional types in return statements. When a function’s return type is a generic conditional type, TypeScript will now use control flow analysis for generic parameters whose types are used in the conditional type, instantiate the conditional type with the narrowed type of each parameter, and relate against that new type.

What does this mean in practice? Well first, let’s look into what kinds of conditional types imply narrowing. To mirror how narrowing operates in expressions, we have to be more explicit and exhaustive about what happens in each branch

type QuickPickReturn<S extends SelectionKind> =
    S extends SelectionKind.Multiple ? string[] :
    S extends SelectionKind.Single ? string :
    never;

Once we’ve done this, everything in our example works. Our callers have no issue, and our implementation is now type-safe! And if we try swapping the contents of our if branches, TypeScript correctly flags it as an error!

    if (selectionKind === SelectionKind.Single) {
        // Oops! Returning an array when the caller expects a single item!
        return selectedItems;
    //  ~~~~~~
    // error! Type 'string[]' is not assignable to type 'string'.
    }
    else {
        // Oops! Returning a single item when the caller expects an array! 
        return selectedItems[0];
    //  ~~~~~~
    // error! Type 'string[]' is not assignable to type 'string'.
}

Note that TypeScript now also does something similar if we’re using indexed access types!
Instead of a conditional type, we can use a type that basically acts as a map from SelectionKind to the return type we want:

interface QuickPickReturn {
    [SelectionKind.Single]: string;
    [SelectionKind.Multiple]: string[];
}

async function showQuickPick<S extends SelectionKind>(
    prompt: string,
    selectionKind: S,
    items: readonly string[],
): Promise<QuickPickReturn[S]> {
    // ...
}

For many users, this will be a more ergonomic way to write the same code.

For more information about this analysis, see the proposal and implementation here!

A Note on Limitations

There are some limitations to this feature. This special checking only kicks in when a single parameter is associated with the type being checked against in a conditional type or used as a key in an indexed access type. If using a conditional type, at least two checks must exist, with a terminal branch including never. That parameter’s type must be generic and have a union type as its constraint. In other words, the code analysis kicks in for a pattern like the following:

function f<T extends A | B>(x: T):
    T extends A ? string :
    T extends B ? number :
    never

This means these checks will not occur when a specific property is associated with a type parameter. For example, if we rewrote our code above to use an options bag instead of individual parameters, TypeScript would not apply this new checking.

interface QuickPickOptions<S> {
    prompt: string,
    selectionKind: S,
    items: readonly string[]
}

async function showQuickPick<S extends SelectionKind>(
    options: QuickPickOptions<S>
): Promise<QuickPickReturn<S>> {
    // narrowing won't work correctly here...
}

But workarounds may exist by writing a conditional type that checks against the inner contents.

type QuickPickReturn<O extends QuickPickOptionsBase> =
    O extends QuickPickOptionsMultiple ? string[] :
    O extends QuickPickOptionsSingle ? string :
    never;

interface QuickPickOptionsBase {
    prompt: string,
    items: readonly string[]
}
interface QuickPickOptionsSingle extends QuickPickOptionsBase {
    selectionKind: SelectionKind.Single;
}
interface QuickPickOptionsMultiple extends QuickPickOptionsBase {
    selectionKind: SelectionKind.Multiple;
}

async function showQuickPick<Opts extends QuickPickOptionsSingle | QuickPickOptionsMultiple>(
    options: Opts
): Promise<QuickPickReturn<Opts>>

These rules may seem like quite a lot to remember, but in practice, most code will not need to leverage these higher-order types. Additionally, while we prefer implementations use this new checking mechanism over type assertions, we do encourage users to keep their APIs simple when possible in the interest of keeping the types written simpler as well. In the meantime, we will continue to explore ways to relax some of these limitations while making the code easy to author.

Support for require() of ECMAScript Modules in --module nodenext

For years, Node.js supported ECMAScript modules (ESM) alongside CommonJS modules. Unfortunately, the interoperability between the two had some challenges.

  • ESM files could import CommonJS files
  • CommonJS files could not require() ESM files

In other words, consuming CommonJS files from ESM files was possible, but not the other way around. This introduced many challenges for library authors who wanted to provide ESM support. These library authors would either have to break compatibility with CommonJS users, "dual-publish" their libraries (providing separate entry-points for ESM and CommonJS), or just stay on CommonJS indefinitely. While dual-publishing might sound like a good middle-ground, it is a complex and error-prone process that also roughly doubles the amount of code within a package.

Node.js 22 relaxes some of these restrictions and permits require("esm") calls from CommonJS modules to ECMAScript modules. Node.js still does not permit require() on ESM files that contain a top-level await, but most other ESM files are now consumable from CommonJS files. This presents a major opportunity for library authors to provide ESM support without having to dual-publish their libraries.

TypeScript 5.8 supports this behavior under the --module nodenext flag. When --module nodenext is enabled, TypeScript will avoid issuing errors on these require() calls to ESM files.

Because this feature may be back-ported to older versions of Node.js, there is currently no stable --module nodeXXXX option that enables this behavior; however, we predict future versions of TypeScript may be able to stabilize the feature under node20. In the meantime, we encourage users of Node.js 22 and newer to use --module nodenext, while library authors and users of older Node.js versions should remain on --module node16 (or make the minor update to --module node18).

For more information, see our support for require("esm") here.

--module node18

TypeScript 5.8 introduces a stable --module node18 flag. For users who are fixed on using Node.js 18, this flag provides a stable point of reference that does not incorporate certain behaviors that are in --module nodenext. Specifically:

  • require() of ECMAScript modules is disallowed under node18, but allowed under nodenext
  • import assertions (deprecated in favor of import attributes) are allowed under node18, but are disallowed under nodenext

See more at both the --module node18 pull request and changes made to --module nodenext.

The --erasableSyntaxOnly Option

Recently, Node.js 23.6 unflagged experimental support for running TypeScript files directly; however, only certain constructs are supported under this mode. Node.js has unflagged a mode called --experimental-strip-types which requires that any TypeScript-specific syntax cannot have runtime semantics. Phrased differently, it must be possible to easily erase or "strip out" any TypeScript-specific syntax from a file, leaving behind a valid JavaScript file.

That means constructs like the following are not supported:

  • enum declarations
  • namespaces and modules with runtime code
  • parameter properties in classes
  • import aliases

Similar tools like ts-blank-space or Amaro (the underlying library for type-stripping in Node.js) have the same limitations. These tools will provide helpful error messages if they encounter code that doesn’t meet these requirements, but you still won’t find out your code doesn’t work until you actually try to run it.

That’s why TypeScript 5.8 introduces the --erasableSyntaxOnly flag. When this flag is enabled, TypeScript will only allow you to use constructs that can be erased from a file, and will issue an error if it encounters any constructs that cannot be erased.

class C {
    constructor(public x: number) { }
    //          ~~~~~~~~~~~~~~~~
    // error! This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
    }
}

For more information, see the implementation here.

The --libReplacement Flag

In TypeScript 4.5, we introduced the possibility of substituting the default lib files with custom ones. This was based on the possibility of resolving a library file from packages named @typescript/lib-*. For example, you could lock your dom libraries onto a specific version of the @types/web package with the following package.json:

{
    "devDependencies": {
       "@typescript/lib-dom": "npm:@types/web@0.0.199"
     }
}

When installed, a package called @typescript/lib-dom should exist, and TypeScript will currently always look it up when dom is implied by your settings.

This is a powerful feature, but it also incurs a bit of extra work. Even if you’re not using this feature, TypeScript always performs this lookup, and has to watch for changes in node_modules in case a lib-replacement package begins to exist.

TypeScript 5.8 introduces the --libReplacement flag, which allows you to disable this behavior. If you’re not using --libReplacement, you can now disable it with --libReplacement false. In the future --libReplacement false may become the default, so if you currently rely on the behavior you should consider explicitly enabling it with --libReplacement true.

For more information, see the change here.

Preserved Computed Property Names in Declaration Files

In an effort to make computed properties have more predictable emit in declaration files, TypeScript 5.8 will consistently preserve entity names (bareVariables and dotted.names.that.look.like.this) in computed property names in classes.

For example, consider the following code:

export let propName = "theAnswer";

export class MyClass {
    [propName] = 42;
//  ~~~~~~~~~~
// error!
// A computed property name in a class property declaration must have a simple literal type or a 'unique symbol' type.
}

Previous versions of TypeScript would issue an error when generating a declaration file for this module, and a best-effort declaration file would generate an index signature.

export declare let propName: string;
export declare class MyClass {
    [x: string]: number;
}

In TypeScript 5.8, the example code is now allowed, and the emitted declaration file will match what you wrote:

export declare let propName: string;
export declare class MyClass {
    [propName]: number;
}

Note that this does not create statically-named properties on the class. You’ll still end up with what is effectively an index signature like [x: string]: number, so for that use case, you’d need to use unique symbols or literal types.

Note that writing this code was and currently is an error under the --isolatedDeclarations flag; but we expect that thanks to this change, computed property names will generally be permitted in declaration emit.

Note that it’s possible (though unlikely) that a file compiled in TypeScript 5.8 may generate a declaration file that is not backward compatible in TypeScript 5.7 or earlier.

For more information, see the implementing PR.

Optimizations on Program Loads and Updates

TypeScript 5.8 introduces a number of optimizations that can both improve the time to build up a program, and also to update a program based on a file change in either --watch mode or editor scenarios.

First, TypeScript now avoids array allocations that would be involved while normalizing paths. Typically, path normalization would involve segmenting each portion of a path into an array of strings, normalizing the resulting path based on relative segments, and then joining them back together using a canonical separator. For projects with many files, this can be a significant and repetitive amount of work. TypeScript now avoids allocating an array, and operates more directly on indexes of the original path.

Additionally, when edits are made that don’t change the fundamental structure of a project, TypeScript now avoids re-validating the options provided to it (e.g. the contents of a tsconfig.json). This means, for example, that a simple edit might not require checking that the output paths of a project don’t conflict with the input paths. Instead, the results of the last check can be used. This should make edits in large projects feel more responsive.

Notable Behavioral Changes

This section highlights a set of noteworthy changes that should be acknowledged and understood as part of any upgrade. Sometimes it will highlight deprecations, removals, and new restrictions. It can also contain bug fixes that are functionally improvements, but which can also affect an existing build by introducing new errors.

lib.d.ts

Types generated for the DOM may have an impact on type-checking your codebase. For more information, see linked issues related to DOM and lib.d.ts updates for this version of TypeScript.

Restrictions on Import Assertions Under --module nodenext

Import assertions were a proposed addition to ECMAScript to ensure certain properties of an import (e.g. "this module is JSON, and is not intended to be executable JavaScript code"). They were reinvented as a proposal called import attributes. As part of the transition, they swapped from using the assert keyword to using the with keyword.

// An import assertion ❌ - not future-compatible with most runtimes.
import data from "./data.json" assert { type: "json" };

// An import attribute ✅ - the preferred way to import a JSON file.
import data from "./data.json" with { type: "json" };

Node.js 22 no longer accepts import assertions using the assert syntax. In turn when --module nodenext is enabled in TypeScript 5.8, TypeScript will issue an error if it encounters an import assertion.

import data from "./data.json" assert { type: "json" };
//                             ~~~~~~
// error! Import assertions have been replaced by import attributes. Use 'with' instead of 'assert'

For more information, see the change here

What’s Next?

At this point, TypeScript 5.8 is "feature-stable". The focus on TypeScript 5.8 will be bug fixes, polish, and certain low-risk editor features. We’ll have a release candidate available in the next few weeks, followed by a stable release soon after. If you’re interested in planning around the release, be sure to keep an eye on our iteration plan which has target release dates and more.

As a note: while beta is a great way to try out the next version of TypeScript, you can also try a nightly build to get the most up-to-date version of TypeScript 5.8 up until our release candidate. Our nightlies are well-tested and can even be used solely in your editor.

So please try out the beta or a nightly release today and let us know what you think!

Happy Hacking!

– Daniel Rosenwasser and the TypeScript Team

Category
TypeScript

Author

Daniel Rosenwasser
Principal Product Manager

Daniel Rosenwasser is the product manager of the TypeScript team. He has a passion for programming languages, compilers, and great developer tooling.

0 comments