Announcing TypeScript 4.2 Beta

Avatar

Daniel

Today we’re excited to announce the availability of TypeScript 4.2 Beta!

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

npm install typescript@beta

You can also get editor support by

Let’s take a look at what’s in store for TypeScript 4.2!

Leading/Middle Rest Elements in Tuple Types

In TypeScript, tuple types are meant to model arrays with specific lengths and element types.

// A tuple that stores a pair of numbers
let a: [number, number] = [1, 2];

// A tuple that stores a string, a number, and a boolean
let b: [string, number, boolean] = ["hello", 42, true];

Over time, TypeScript’s tuple types have become more and more sophisticated, since they’re also used to model things like parameter lists in JavaScript. As a result, they can have optional elements and rest elements, and can even have labels for tooling and readability.

// A tuple that has either one or two strings.
let c: [string, string?] = ["hello"];
c = ["hello", "world"];

// A labeled tuple that has either one or two strings.
let d: [first: string, second?: string] = ["hello"];
d = ["hello", "world"];

// A tuple with a *rest element* - holds at least 2 strings at the front,
// and any number of booleans at the back.
let e: [string, string, ...boolean[]];

e = ["hello", "world"];
e = ["hello", "world", false];
e = ["hello", "world", true, false, true];

In TypeScript 4.2, rest elements specifically been expanded in how they can be used. In prior versions, TypeScript only allowed ...rest elements at the very last position of a tuple type.

However, now rest elements can occur anywhere within a tuple – with only a few restrictions.

let foo: [...string[], number];

foo = [123];
foo = ["hello", 123];
foo = ["hello!", "hello!", "hello!", 123];

let bar: [boolean, ...string[], boolean];

bar = [true, false];
bar = [true, "some text", false];
bar = [true, "some", "separated", "text", false];

The only restriction is that a rest element can be placed anywhere in a tuple, so long as it’s not followed by another optional element or rest element. In other words, only one rest element per tuple, and no optional elements after rest elements.

interface Clown { /*...*/ }
interface Joker { /*...*/ }

let StealersWheel: [...Clown[], "me", ...Joker[]];
//                                    ~~~~~~~~~~ Error!
// A rest element cannot follow another rest element.

let StringssAndMaybeBoolean: [...string[], boolean?];
//                                         ~~~~~~~~ Error!
// An optional element cannot follow a rest element.

These non-trailing rest elements can be used to model functions that take any number of leading arguments, followed by a few fixed ones.

declare function doStuff(...args: [...names: string[], shouldCapitalize: boolean]): void;

doStuff(/*shouldCapitalize:*/ false)
doStuff("fee", "fi", "fo", "fum", /*shouldCapitalize:*/ true);

Even though JavaScript doesn’t have any syntax to model leading rest parameters, we were still able to declare doStuff as a function that takes leading arguments by declaring the ...args rest parameter with a tuple type that uses a leading rest element. This can help model lots of existing JavaScript out there!

For more details, see the original pull request.

Smarter Type Alias Preservation

TypeScript has always used a set of heuristics for when and how to display type aliases; but these techniques often have issues beyond simple uses. For example, take the following code snippet.

export type BasicPrimitive = number | string | boolean;

export function doStuff(value: BasicPrimitive) {
    let x = value;
    return x;
}

If we hover our mouse over x in an editor like Visual Studio, Visual Studio Code, or the TypeScript Playground, we’ll get a quick info panel that shows the type BasicPrimitive. Likewise, if we get the declaration file output (.d.ts output) for this file, TypeScript will say that doStuff returns BasicPrimitive.

However, what happens if we return a BasicPrimitive or undefined?

export type BasicPrimitive = number | string | boolean;

export function doStuff(value: BasicPrimitive) {
    if (Math.random() < 0.5) {
        return undefined;
    }

    return value;
}

We can see what happens in the TypeScript playground. While we might want TypeScript to display the return type of doStuff as BasicPrimitive | undefined, it instead displays string | number | boolean | undefined! What gives?

Well this has to do with how TypeScript represents types internally. When creating a union type out of one or more union types, it will always normalize those types into a new flattened union type – but doing that loses information. The type-checker didn’t have a way to know where that string | number | boolean had come from.

In TypeScript 4.2, our internals are a little smarter. We keep track of how types were constructed by keeping around parts of their original structures – before we normalize them. We also keep track of, and differentiate, type aliases to instances of other aliases!

Being able to print back the types based on how you used them in your code means that as a TypeScript user, you can avoid some unfortunately humongous types getting displayed, and that often translates to getting better .d.ts file output, error messages, and in-editor type displays in quick info and signature help.

More more information, check out the first pull request that improves various cases around preserving union type aliases, along with a second pull request that preserves indirect aliases.

Template Literal Expressions Have Template Literal Types

In TypeScript 4.1, we introduced a new kind of type: template literal types. These types are able to model specific patterns of strings.

type GreetingStart = "hello" | "hi" | "sup";

declare function greet(str: `${GreetingStart} ${string}`): void;

// Works.
greet("hello world!");

// Works.
greet("hi everybody!");

// Error!
// Doesn't work with any of the patterns:
//   `hello ${string}` | `hi ${string}` | `sup ${string}`
greet("hallo yes hi sup");

However, in 4.1 there was a bit of a strange inconsistency between template string types and template string expressions.

function doStuff(str: string) {
    // Error!
    // Type 'string' is not assignable to type '`hello ${string}`'.
    let x: `hello ${string}` = `hello ${str}`
}

This is because template string expressions with substitution slots ${likeThis} have always just had the type string. In turn, those couldn’t be compatible with our new template string types.

In TypeScript 4.2, template string expressions now always start out with template literal types. Similarly to string literal types, these types disappear and turn into string through a process called widening if we assign one of these values to a mutable variable.

const n: number = 123;

// Has the type `${number}px`
const s1 = `${n}px`;  

// Works!
const s2: `${number}px` = s1;

// Error!
const s3: `${number}pt` = s1;

// Has the type 'string' because of widening.
let v1 = s1;

You can read up more at the implementing pull request.

Stricter Checks for the in Operator

In JavaScript, it is a runtime error to use a non-object type on the right side of the in operator. TypeScript 4.2 ensures this can be caught at design-time.

"foo" in 42
//       ~~
// error! The right-hand side of an 'in' expression must not be a primitive.

This check is fairly conservative for the most part, so if you have received an error about this, it is likely an issue in the code.

A big thanks to our external contributor Jonas H├╝botter for their pull request!

--noPropertyAccessFromIndexSignature

Back when TypeScript first introduced index signatures, you could only get properties declared by them with “bracketed” element access syntax like person["name"].

interface SomeType {
    /** This is an index signature. */
    [propName: string]: any;
}

function doStuff(value: SomeType) {
    let x = value["someProperty"];
}

This ended up being cumbersome in situations where we need to work with objects that have arbitrary properties. For example, imagine an API where it’s common to misspell a property name by adding an extra s character at the end.

interface Options {
    /** File patterns to be excluded. */
    exclude?: string[];

    /**
     * It handles any extra properties that we haven't declared as type 'any'.
     */
    [x: string]: any;
}

function processOptions(opts: Options) {
    // Notice we're *intentionally* accessing `excludes`, not `exclude`
    if (opts.excludes) {
        console.error("The option `excludes` is not valid. Did you mean `exclude`?");
    }
}

To make these types of situations easier, a while back, TypeScript made it possible to use “dotted” property access syntax like person.name when a type had a string index signature. This also made it easier to transition existing JavaScript code over to TypeScript.

However, loosening the restriction also meant that misspelling an explicitly declared property became much easier.

function processOptions(opts: Options) {
    // ...

    // Notice we're *accidentally* accessing `excludes` this time.
    // Oops! Totally valid.
    for (const excludePattern of opts.excludes) {
        // ...
    }
}

In some cases, users would prefer to explicitly opt into the index signature – they would prefer to get an error message when a dotted property access doesn’t correspond to a specific property declaration.

That’s why TypeScript introduces a new flag called --noPropertyAccessFromIndexSignature. Under this mode, you’ll be opted in to TypeScript’s older behavior that issues an error. This new setting is not under the strict family of flags, since we believe users will find it more useful on certain codebases than others.

You can understand this feature in more detail by reading up on the corresponding pull request. We’d also like to extend a big thanks to Wenlu Wang who sent us this pull request!

abstract Construct Signatures

TypeScript allows us to mark a class as abstract. This tells TypeScript that the class is only meant to be extended from, and that certain members need to be filled in by any subclass to actually create an instance.

abstract class Shape {
    abstract getArea(): number;
}

// Error! Can't instantiate an abstract class.
new Shape();

class Square extends Shape {
    #sideLength: number;

    constructor(sideLength: number) {
        this.#sideLength = sideLength;
    }

    getArea() {
        return this.#sideLength ** 2;
    }
}

// Works fine.
new Square(42);

To make sure this restriction in new-ing up abstract classes is consistently applied, you can’t assign an abstract class to anything that expects a construct signature.

interface HasArea {
    getArea(): number;
}

// Error! Cannot assign an abstract constructor type to a non-abstract constructor type.
let Ctor: new () => HasArea = Shape;

This does the right thing in case we intend to run code like new Ctor, but it’s overly-restrictive in case we want to write a subclass of Ctor.

functon makeSubclassWithArea(Ctor: new () => HasArea) {
    return class extends Ctor {
        getArea() {
            // ...
        }
    }
}

let MyShape = makeSubclassWithArea(Shape);

It also doesn’t work well with built-in helper types like InstanceType.

// Error!
// Type 'typeof Shape' does not satisfy the constraint 'new (...args: any) => any'.
//   Cannot assign an abstract constructor type to a non-abstract constructor type.
type MyInstance = InstanceType<typeof Shape>;

That’s why TypeScript 4.2 allows you to specify an abstract modifier on constructor signatures.

interface HasArea {
    getArea(): number;
}

// Works!
let Ctor: abstract new () => HasArea = Shape;
//        ^^^^^^^^

Adding the abstract modifier to a construct signature signals that you can pass in abstract constructors. It doesn’t stop you from passing in other classes/constructor functions that are “concrete” – it really just signals that there’s no intent to run the constructor directly, so it’s safe to pass in either class type.

This feature allows us to write mixin factories in a way that supports abstract classes. For example, in the following code snippet, we’re able to use the mixin function withStyles with the abstract class SuperClass.

abstract class SuperClass {
    abstract someMethod(): void;
    badda() {}
}

type AbstractConstructor<T> = abstract new (...args: any[]) => T

function withStyles<T extends AbstractConstructor<object>>(Ctor: T) {
    abstract class StyledClass extends Ctor {
        getStyles() {
            // ...
        }
    }
    return StyledClass;
}

class SubClass extends withStyles(SuperClass) {
    someMethod() {
        this.someMethod()
    }
}

Note that withStyles is demonstrating a specific rule, where a class (like StyledClass) that extends a value that’s generic and bounded by an abstract constructor (like Ctor) has to also be declared abstract. This is because there’s no way to know if a class with more abstract members was passed in, and so it’s impossible to know whether the subclass implements all the abstract members.

You can read up more on abstract construct signatures on its pull request.

--explainFiles to understand why files have been included in a program

A surprisingly common scenario for TypeScript users is to ask “why is TypeScript including this file?”. Inferring the files of your program turns out to be a complicated process, and so there are lots of reasons why a specific combination of lib.d.ts was used, why certain files in node_modules are getting included, and why certain files are being included even though we thought specifying exclude would keep them out.

That’s why TypeScript now provides an --explainFiles flag.

tsc --explainFiles

When using this option, the TypeScript compiler will give some very verbose output about why a file ended up in your program. To read it more easily, you can forward the output to a file, or pipe it to a program that can easily view it.

# Forward output to a text file
tsc --explainFiles > expanation.txt

# Pipe output to a utility program like `less`, or an editor like VS Code
tsc --explainFiles | less

tsc --explainFiles | code -

Typically, the output will start out by listing out reasons for including lib.d.ts files, then for local files, and then node_modules files.

TS_Compiler_Directory/4.2.0-beta/lib/lib.es5.d.ts
  Library referenced via 'es5' from file 'TS_Compiler_Directory/4.2.0-beta/lib/lib.es2015.d.ts'
TS_Compiler_Directory/4.2.0-beta/lib/lib.es2015.d.ts
  Library referenced via 'es2015' from file 'TS_Compiler_Directory/4.2.0-beta/lib/lib.es2016.d.ts'
TS_Compiler_Directory/4.2.0-beta/lib/lib.es2016.d.ts
  Library referenced via 'es2016' from file 'TS_Compiler_Directory/4.2.0-beta/lib/lib.es2017.d.ts'
TS_Compiler_Directory/4.2.0-beta/lib/lib.es2017.d.ts
  Library referenced via 'es2017' from file 'TS_Compiler_Directory/4.2.0-beta/lib/lib.es2018.d.ts'
TS_Compiler_Directory/4.2.0-beta/lib/lib.es2018.d.ts
  Library referenced via 'es2018' from file 'TS_Compiler_Directory/4.2.0-beta/lib/lib.es2019.d.ts'
TS_Compiler_Directory/4.2.0-beta/lib/lib.es2019.d.ts
  Library referenced via 'es2019' from file 'TS_Compiler_Directory/4.2.0-beta/lib/lib.es2020.d.ts'
TS_Compiler_Directory/4.2.0-beta/lib/lib.es2020.d.ts
  Library referenced via 'es2020' from file 'TS_Compiler_Directory/4.2.0-beta/lib/lib.esnext.d.ts'
TS_Compiler_Directory/4.2.0-beta/lib/lib.esnext.d.ts
  Library 'lib.esnext.d.ts' specified in compilerOptions

... More Library References...

foo.ts
  Matched by include pattern '**/*' in 'tsconfig.json'

Right now, we make no guarantees about the output format – it might change over time. On that note, we’re interested in improving this format if you have any suggestions!

For more information, check out the original pull request!

Relaxed Rules Between Optional Properties and String Index Signatures

String index signatures are a way of typing dictionary-like objects, where you want to allow access with arbitrary keys:

const movieWatchCount: { [key: string]: number } = {};

function watchMovie(title: string) {
  movieWatchCount[title] = (movieWatchCount[title] ?? 0) + 1;
}

Of course, for any movie title not yet in the dictionary, movieWatchCount[title] will be undefined (TypeScript 4.1 added the option --noUncheckedIndexedAccess to include undefined when reading from an index signature like this). Even though it’s clear that there must be some strings not present in movieWatchCount, previous versions of TypeScript treated optional object properties as unassignable to otherwise compatible index signatures, due to the presence of undefined.

type WesAndersonWatchCount = {
  "Fantastic Mr. Fox"?: number;
  "The Royal Tenenbaums"?: number;
  "Moonrise Kingdom"?: number;
  "The Grand Budapest Hotel"?: number;
};

declare const wesAndersonWatchCount: WesAndersonWatchCount;
const movieWatchCount: { [key: string]: number } = wesAndersonWatchCount;
//    ~~~~~~~~~~~~~~~ error!
// Type 'WesAndersonWatchCount' is not assignable to type '{ [key: string]: number; }'.
//    Property '"Fantastic Mr. Fox"' is incompatible with index signature.
//      Type 'number | undefined' is not assignable to type 'number'.
//        Type 'undefined' is not assignable to type 'number'. (2322)

TypeScript 4.2 allows this assignment. However, it does not allow the assignment of non-optional properties with undefined in their types, nor does it allow writing undefined to a specific key:

type BatmanWatchCount = {
  "Batman Begins": number | undefined;
  "The Dark Knight": number | undefined;
  "The Dark Knight Rises": number | undefined;
};

declare const batmanWatchCount: BatmanWatchCount;

// Still an error in TypeScript 4.2.
// `undefined` is only ignored when properties are marked optional.
const movieWatchCount: { [key: string]: number } = batmanWatchCount;

// Still an error in TypeScript 4.2.
// Index signatures don't implicitly allow explicit `undefined`.
movieWatchCount["It's the Great Pumpkin, Charlie Brown"] = undefined;

The new rule also does not apply to number index signatures, since they are assumed to be array-like and dense:

declare let sortOfArrayish: { [key: number]: string };
declare let numberKeys: { 42?: string };

// Error! Type '{ 42?: string | undefined; }' is not assignable to type '{ [key: number]: string; }'.
sortOfArrayish = numberKeys;

You can get a better sense of this change by reading up on the original PR.

Declare Missing Helper Function

Thanks to a community pull request from Alexander Tarasyuk, we now have a quick fix for declaring new functions and methods based on the call-site!

An un-declared function foo being called, with a quick fix scaffolding out the new contents of the file

Breaking Changes

We always strive to minimize breaking changes in a release. TypeScript 4.2 contains some breaking changes, but we believe they should be manageable in an upgrade.

Template Literal Expressions Have Template Literal Types

As mentioned previously, template string expressions now start out with template literal types.

const n: number = 123;
const s1 = `${n}px`;  // `${number}px`
const s2: `${number}px` = s1;
const s3: `${number}pt` = s1;  // Error
let v1 = s1;  // string (because of widening)

This is a break, as these values used to just have the type string.

See the respective pull request for more details.

noImplicitAny Errors Apply to Loose yield Expressions

When a yield expression is captured, but isn’t contextually typed (i.e. TypeScript can’t figure out what the type is), TypeScript will now issue an implicit any error.

function* g1() {
  const value = yield 1; // report implicit any error
}

function* g2() {
  yield 1; // result is unused, no error
}

function* g3() {
  const value: string = yield 1; // result is contextually typed by type annotation of `value`, no error.
}

function* g3(): Generator<number, void, string> {
  const value = yield 1; // result is contextually typed by return-type annotation of `g3`, no error.
}

See more details in the corresponding changes.

Type Arguments in JavaScript Are Not Parsed as Type Arguments

Type arguments were already not allowed in JavaScript, but in TypeScript 4.2, the parser will parse them in a more spec-compliant way. So when writing the following code in a JavaScript file:

f<T>(100)

TypeScript will parse it as the following JavaScript:

(f < T) > (100)

This may impact you if you were leveraging TypeScript’s API to parse type constructs in JavaScript files, which may have occurred when trying to parse Flow files.

The in Operator No Longer Allows Primitive Types on the Right Side

As mentioned, it is an error to use a primitive on the right side of an in operator, and TypeScript 4.2 is stricter about this sort of code.

"foo" in 42
//       ~~
// error! The right-hand side of an 'in' expression must not be a primitive.

See the pull request for more details on what’s checked.

TypeScript’s lift Callback in visitNode Uses a Different Type

TypeScript has a visitNode function that takes a lift function. lift now expects a readonly Node[] instead of a NodeArray<Node>. This is technically an API breaking change which you can read more on here.

What’s Next?

We’re excited to hear your thoughts on TypeScript 4.2! With the beta we’re still in relatively early stages, but we’re counting on your feedback to help make this an excellent release. So try it today, and let us know if you run into anything!

Happy Hacking!

– Daniel Rosenwasser and the TypeScript Team

0 comments

Leave a comment