Announcing TypeScript 4.6 RC

Daniel Rosenwasser

Today we’re excited to announce our Release Candidate (RC) of TypeScript 4.6! Between now and the stable release of TypeScript 4.6, we expect no further changes apart from critical bug fixes.

To get started using the RC, you can get it through NuGet, or use npm with the following command:

npm install typescript@rc

You can also get editor support by

If you’ve already read our beta blog post, you can read up on what’s changed since.

Here’s a quick list of what’s new in TypeScript 4.6!

What’s New Since the Beta?

When we announced our beta, we missed two great features – control flow analysis for destructured discriminated unions and the es2022 output target. Otherwise, our team has mostly been working on bug fixes and investigating performance improvements.

Allowing Code in Constructors Before super()

In JavaScript classes it’s mandatory to call super() before referring to this. TypeScript enforces this as well, though it was a bit too strict in how it ensured this. In TypeScript, it was previously an error to contain any code at the beginning of a constructor if its containing class had any property initializers.

class Base {
    // ...
}

class Derived extends Base {
    someProperty = true;

    constructor() {
        // error!
        // have to call 'super()' first because it needs to initialize 'someProperty'.
        doSomeStuff();
        super();
    }
}

This made it cheap to check that super() got called before this was referenced, but this ends up rejecting a lot of valid code. TypeScript 4.6 is now much more lenient in that check and permits other code to run before super()., all while still ensuring that super() occurs at the top-level before any references to this.

We’d like to extend our thanks to Joshua Goldberg for patiently working with us to land this change!

Control Flow Analysis for Destructured Discriminated Unions

TypeScript is able to narrow types based on what’s called a discriminant property. For example, in the following code snippet, TypeScript is able to narrow the type of action based on every time we check against the value of kind.

type Action =
    | { kind: "NumberContents", payload: number }
    | { kind: "StringContents", payload: string };

function processAction(action: Action) {
    if (action.kind === "NumberContents") {
        // `action.payload` is a number here.
        let num = action.payload * 2
        // ...
    }
    else if (action.kind === "StringContents") {
        // `action.payload` is a string here.
        const str = action.payload.trim();
        // ...
    }
}

This lets us work with objects that can hold different data, but a common field tells us which data those objects have.

This is very common in TypeScript; however, depending on your preferences, you might have wanted to destructure kind and payload in the the example above. Perhaps something like the following:

type Action =
    | { kind: "NumberContents", payload: number }
    | { kind: "StringContents", payload: string };

function processAction(action: Action) {
    const { kind, payload } = action;
    if (kind === "NumberContents") {
        let num = payload * 2
        // ...
    }
    else if (kind === "StringContents") {
        const str = payload.trim();
        // ...
    }
}

Previously TypeScript would error on these – once kind and payload were extracted from the same object into variables, they were considered totally independent.

In TypeScript 4.6, this just works!

When destructuring individual properties into a const declaration, or when destructuring a parameter into variables that are never assigned to, TypeScript will check for if the destructured type is a discriminated union. If it is, TypeScript can now narrow the types of variables depending on checks of other variables So in our example, a check on kind narrows the type of payload.

For more information, see the pull request that implemented this analysis.

Improved Recursion Depth Checks

TypeScript has some interesting challenges due to the fact that it’s built on a structural type system that also provides generics.

In a structural type system, object types are compatible based on the members they have.

interface Source {
    prop: string;
}

interface Target {
    prop: number;
}

function check(source: Source, target: Target) {
    target = source;
    // error!
    // Type 'Source' is not assignable to type 'Target'.
    //   Types of property 'prop' are incompatible.
    //     Type 'string' is not assignable to type 'number'.
}

Notice that whether or not Source is compatible with Target has to do with whether their properties are assignable. In this case, that’s just prop.

When you introduce generics into this, there are some harder questions to answer. For instance, is a Source<string> assignable to a Target<number> in the following case?

interface Source<T> {
    prop: Source<Source<T>>;
}

interface Target<T> {
    prop: Target<Target<T>>;
}

function check(source: Source<string>, target: Target<number>) {
    target = source;
}

In order to answer that, TypeScript needs to check whether the types of prop are compatible. That leads to the another question: is a Source<Source<string>> assignable to a Target<Target<number>>? To answer that, TypeScript checks whether prop is compatible for those types, and ends up checking whether Source<Source<Source<string>>> is assignable to Target<Target<Target<number>>>. Keep going for a bit, and you might notice that the type infinitely expands the more you dig in.

TypeScript has a few heuristics here – if a type appears to be infinitely expanding after encountering a certain depth check, then it considers that the types could be compatible. This is usually enough, but embarrassingly there were some false-negatives that this wouldn’t catch.

interface Foo<T> {
    prop: T;
}

declare let x: Foo<Foo<Foo<Foo<Foo<Foo<string>>>>>>;
declare let y: Foo<Foo<Foo<Foo<Foo<string>>>>>;

x = y;

A human reader can see that x and y should be incompatible in the above example. While the types are deeply nested, that’s just a consequence of how they were declared. The heuristic was meant to capture cases where deeply-nested types were generated through exploring the types, not from when a developer wrote that type out themselves.

TypeScript 4.6 is now able to distinguish these cases, and correctly errors on the last example. Additionally, because the language is no longer concerned with false-positives from explicitly-written types, TypeScript can conclude that a type is infinitely expanding much earlier, and save a bunch of work in checking for type compatibility. As a result, libraries on DefinitelyTyped like redux-immutable, react-lazylog, and yup saw a 50% reduction in check-time.

You may already have this change because it was cherry-picked into TypeScript 4.5.3, but it is a notable feature of TypeScript 4.6 which you can read up more about here.

Indexed Access Inference Improvements

TypeScript now can correctly infer to indexed access types which immediately index into a mapped object type.

interface TypeMap {
    "number": number;
    "string": string;
    "boolean": boolean;
}

type UnionRecord<P extends keyof TypeMap> = { [K in P]:
    {
        kind: K;
        v: TypeMap[K];
        f: (p: TypeMap[K]) => void;
    }
}[P];

function processRecord<K extends keyof TypeMap>(record: UnionRecord<K>) {
    record.f(record.v);
}

processRecord({
    kind: "string",
    v: "hello!",

    // 'val' used to implicitly have the type 'string | number | boolean',
    // but now is correctly inferred to just 'string'.
    f: val => {
        console.log(val.toUpperCase());
    }
})

This pattern was already supported and allowed TypeScript to understand that the call to record.f(record.v) is valid, but previously the call to processRecord would give poor inference results for val

TypeScript 4.6 improves this so that no type assertions are necessary within the call to processRecord.

For more information, you can read up on the pull request.

Control Flow Analysis for Dependent Parameters

A signature can be declared with a rest parameter whose type is a discriminated union of tuples.

function func(...args: ["str", string] | ["num", number]) {
    // ...
}

What this says is that when the first argument is the string "str", then its second argument is a string, and when its first argument is the string "num", its second argument is a number.

In cases where TypeScript infers from a signature with this kind of rest parameter, TypeScript can now narrow parameters that depend on others.

type Func = (...args: ["a", number] | ["b", string]) => void;

const f1: Func = (kind, payload) => {
    if (kind === "a") {
        payload.toFixed();  // 'payload' narrowed to 'number'
    }
    if (kind === "b") {
        payload.toUpperCase();  // 'payload' narrowed to 'string'
    }
};

f1("a", 42);
f1("b", "hello");

For more information, see the change on GitHub.

--target es2022

TypeScript’s --target option now supports es2022. This means features like class fields now have a stable output target where they can be preserved. It also means that new built-in functionality like the at() method on Arrays, Object.hasOwn, or the cause option on new Error can be used either with this new --target setting, or with --lib es2022.

This functionality was implemented by Kagami Sascha Rosylight (saschanaz) over several PRs, and we’re grateful for that contribution!

More Syntax and Binding Errors in JavaScript

TypeScript has expanded its set of syntax and binding errors in JavaScript files. You’ll see these new errors if you open JavaScript files in an editor like Visual Studio or Visual Studio Code, or if you run JavaScript code through the TypeScript compiler – even if you don’t turn on checkJs or add a // @ts-check comment to the top of your files.

As one example, if you have two declarations of a const in the same scope of a JavaScript file, TypeScript will now issue an error on those declarations.

const foo = 1234;
//    ~~~
// error: Cannot redeclare block-scoped variable 'foo'.

// ...

const foo = 5678;
//    ~~~
// error: Cannot redeclare block-scoped variable 'foo'.

As another example, TypeScript will let you know if a modifier is being incorrectly used.

function container() {
    export function foo() {
//  ~~~~~~
// error: Modifiers cannot appear here.

    }
}

These errors can be disabled by adding a // @ts-nocheck at the top of your file, but we’re interested in hearing some early feedback about how it works for your JavaScript workflow. You can easily try it out for Visual Studio Code by installing the TypeScript and JavaScript Nightly Extension, and read up more on the first and second pull requests.

TypeScript Trace Analyzer

Occasionally, teams may encounter types that are computationally expensive to create and compare. TypeScript has a --generateTrace flag to help identify some of those expensive types, or sometimes help diagnose issues in the TypeScript compiler. While the information generated by --generateTrace can be useful (especially with some information added in TypeScript 4.6), it can often be hard to read in existing trace visualizers.

We recently published a tool called @typescript/analyze-trace to get a more digestible view of this information. While we don’t expect everyone to need analyze-trace, we think it can come in handy for any team that is running into build performance issues with TypeScript.

For more information, see the analyze-trace tool’s repo.

Breaking Changes

Object Rests Drop Unspreadable Members from Generic Objects

Object rest expressions now drop members that appear to be unspreadable on generic objects. In the following example…

class Thing {
    someProperty = 42;

    someMethod() {
        // ...
    }
}

function foo<T extends Thing>(x: T) {
    let { someProperty, ...rest } = x;

    // Used to work, is now an error!
    // Property 'someMethod' does not exist on type 'Omit<T, "someProperty" | "someMethod">'.
    rest.someMethod();
}

the variable rest used to have the type Omit<T, "someProperty"> because TypeScript would strictly analyze which other properties were destructured. This doesn’t model how ...rest would work in a destructuring from a non-generic type because someMethod would typically be dropped as well. In TypeScript 4.6, the type of rest is Omit<T, "someProperty" | "someMethod">.

This can also come up in cases when destructuring from this. When destructuring this using a ...rest element, unspreadable and non-public members are now dropped, which is consistent with destructuring instances of a class in other places.

class Thing {
    someProperty = 42;

    someMethod() {
        // ...
    }

    someOtherMethod() {
        let { someProperty, ...rest } = this;

        // Used to work, is now an error!
        // Property 'someMethod' does not exist on type 'Omit<T, "someProperty" | "someMethod">'.
        rest.someMethod();
    }
}

For more details, see the corresponding change here.

JavaScript Files Always Receive Grammar and Binding Errors

Previously, TypeScript would ignore most grammar errors in JavaScript apart from accidentally using TypeScript syntax in a JavaScript file. TypeScript now shows JavaScript syntax and binding errors in your file, such as using incorrect modifiers, duplicate declarations, and more. These will typically be most apparent in Visual Studio Code or Visual Studio, but can also occur when running JavaScript code through the TypeScript compiler.

You can explicitly turn these errors off by inserting a // @ts-nocheck comment at the top of your file.

For more information, see the first and second implementing pull requests for these features.

What’s Next?

In the next two weeks or so we’ll be releasing our stable version of TypeScript 4.6 – that’s why we want to hear about your experience with our release candidate! Don’t hesitate to reach out on our issue tracker and let us know if you run into anything.

Happy Hacking!

– Daniel Rosenwasser and the TypeScript Team

0 comments

Discussion is closed.

Feedback usabilla icon