Announcing TypeScript 3.8 Beta

Avatar

Daniel

Today we’re announcing the availability of TypeScript 3.8 Beta! This Beta release contains all the new features you should expect from TypeScript 3.8’s final release.

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

npm install typescript@beta

You can also get editor support by

TypeScript 3.8 brings a lot of new features, including new or upcoming ECMAScript standards features, new syntax for importing/exporting only types, and more.

Type-Only Imports and Export

TypeScript reuses JavaScript’s import syntax in order to let us reference types. For instance, in the following example, we’re able to import doThing which is a JavaScript value along with Options which is purely a TypeScript type.

// ./foo.ts
interface Options {
    // ...
}

export function doThing(options: Options) {
    // ...
}

// ./bar.ts
import { doThing, Options } from "./foo.js";

function doThingBetter(options: Options) {
    // do something twice as good
    doThing(options);
    doThing(options);
}

This is convenient because most of the time we don’t have to worry about what’s being imported – just that we’re importing something.

Unfortunately, this only worked because of a feature called import elision. When TypeScript outputs JavaScript files, it sees that Options is only used as a type, and it automatically drops its import. The resulting output looks kind of like this:

// ./foo.js
export function doThing(options: Options) {
    // ...
}

// ./bar.js
import { doThing } from "./foo.js";

function doThingBetter(options: Options) {
    // do something twice as good
    doThing(options);
    doThing(options);
}

Again, this behavior is usually great, but it causes some other problems.

First of all, there are some places where it’s ambiguous whether a value or a type is being exported. For example, in the following example is MyThing a value or a type?

import { MyThing } from "./some-module.js";

export { MyThing };

Limiting ourselves to just this file, there’s no way to know. Both Babel and TypeScript’s transpileModule API will emit code that doesn’t work correctly if MyThing is only a type, and TypeScript’s isolatedModules flag will warn us that it’ll be a problem. The real problem here is that there’s no way to say “no, no, I really only meant the type – this should be erased”, so import elision isn’t good enough.

The other issue was that TypeScript’s import elision would get rid of import statements that only contained imports used as types. That caused observably different behavior for modules that have side-effects, and so users would have to insert a second import statement purely to ensure side-effects.

// This statement will get erased because of import elision.
import { SomeTypeFoo, SomeOtherTypeBar } from "./module-with-side-effects";

// This statement always sticks around.
import "./module-with-side-effects";

A concrete place where we saw this coming up was in frameworks like Angular.js (1.x) where services needed to be registered globally (which is a side-effect), but where those services were only imported for types.

// ./service.ts
export class Service {
    // ...
}
register("globalServiceId", Service);

// ./consumer.ts
import { Service } from "./service.js";

inject("globalServiceId", function (service: Service) {
    // do stuff with Service
});

As a result, ./service.js will never get run, and things will break at runtime.

To avoid this class of issues, we realized we needed to give users more fine-grained control over how things were getting imported/elided.

As a solution in TypeScript 3.8, we’ve added a new syntax for type-only imports and exports.

import type { SomeThing } from "./some-module.js";

export type { SomeThing };

import type only imports declarations to be used for type annotations and declarations. It always gets fully erased, so there’s no remnant of it at runtime. Similarly, export type only provides an export that can be used for type contexts, and is also erased from TypeScript’s output.

It’s important to note that classes have a value at runtime and a type at design-time, and the use is very context-sensitive. When using import type to import a class, you can’t do things like extend from it.

import type { Component } from "react";

interface ButtonProps {
    // ...
}

class Button extends Component<ButtonProps> {
    //               ~~~~~~~~~
    // error! 'Component' only refers to a type, but is being used as a value here.

    // ...
}

If you’ve used Flow before, the syntax is fairly similar. One difference is that we’ve added a few restrictions to avoid code that might appear ambiguous.

// Is only 'Foo' a type? Or every declaration in the import?
// We just give an error because it's not clear.

import type Foo, { Bar, Baz } from "some-module";
//     ~~~~~~~~~~~~~~~~~~~~~~
// error! A type-only import can specify a default import or named bindings, but not both.

In conjunction with import type, we’ve also added a new compiler flag to control what happens with imports that won’t be utilized at runtime: importsNotUsedAsValues. At this point the name is tentative, but this flag takes 3 different options:

  • remove: this is today’s behavior of dropping these imports. It’s going to continue to be the default, and is a non-breaking change.
  • preserve: this preserves all imports whose values are never used. This can cause imports/side-effects to be preserved.
  • error: this preserves all imports (the same as the preserve option), but will error when a value import is only used as a type. This might be useful if you want to ensure no values are being accidentally imported, but still make side-effect imports explicit.

For more information about the feature, you can take a look at the pull request.

Type-Only vs Erased

There is a final note about this feature. In TypeScript 3.8 Beta, only the type meaning of a declaration will be imported by import type. That means that you can’t use values even if they’re purely used for type positions (like in the extends clause of a class declared with the declare modifier, and the typeof type operator).

import type { Base } from "my-library";

let baseConstructor: typeof Base;
//                          ~~~~
// error! 'Base' only refers to a type, but is being used as a value here.

declare class Derived extends Base {
    //                        ~~~~
    // error! 'Base' only refers to a type, but is being used as a value here.
}

We’re looking at changing this behavior based on recent feedback. Instead of only importing the type side of declarations, we’re planning on changing the meaning of import type to mean “import whatever this is, but only allow it in type positions.” In other words, things imported using import type can only be used in places where it won’t affect surrounding JavaScript code.

While this behavior is not in the beta, you can expect it in our upcoming release candidate, and keep track of that work on its respective pull request.

ECMAScript Private Fields

TypeScript 3.8 brings support for ECMAScript’s private fields, part of the stage-3 class fields proposal. This work was started and driven to completion by our good friends at Bloomberg!

class Person {
    #name: string
    
    constructor(name: string) {
        this.#name = name;
    }

    greet() {
        console.log(`Hello, my name is ${this.#name}!`);
    }
}

let jeremy = new Person("Jeremy Bearimy");

jeremy.#name
//     ~~~~~
// Property '#name' is not accessible outside class 'Person'
// because it has a private identifier.

Unlike regular properties (even ones declared with the private modifier), private fields have a few rules to keep in mind. Some of them are:

  • Private fields start with a # character. Sometimes we call these private names.
  • Every private field name is uniquely scoped to its containing class.
  • TypeScript accessibility modifiers like public or private can’t be used on private fields.
  • Private fields can’t be accessed or even detected outside of the containing class – even by JS users! Sometimes we call this hard privacy.

Apart from “hard” privacy, another benefit of private fields is that uniqueness we just mentioned. For example, regular property declarations are prone to being overwritten in subclasses.

class C {
    foo = 10;

    cHelper() {
        return this.foo;
    }
}

class D extends C {
    foo = 20;

    dHelper() {
        return this.foo;
    }
}

let instance = new D();
// 'this.foo' refers to the same property on each instance.
console.log(instance.cHelper()); // prints '20'
console.log(instance.dHelper()); // prints '20'

With private fields, you’ll never have to worry about this, since each field name is unique to the containing class.

class C {
    #foo = 10;

    cHelper() {
        return this.#foo;
    }
}

class D extends C {
    #foo = 20;

    dHelper() {
        return this.#foo;
    }
}

let instance = new D();
// 'this.#foo' refers to a different field within each class.
console.log(instance.cHelper()); // prints '10'
console.log(instance.dHelper()); // prints '20'

Another thing worth noting is that accessing a private field on any other type will result in a TypeError!

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

    equals(other: any) {
        return this.#sideLength === other.#sideLength;
    }
}

const a = new Square(100);
const b = { sideLength: 100 };

// Boom!
// TypeError: attempted to get private field on non-instance
// This fails because 'b' is not an instance of 'Square'.
console.log(a.equals(b));

Finally, for any plain .js file users, private fields always have to be declared before they’re assigned to.

class C {
    // No declaration for '#foo'
    // :(

    constructor(foo: number) {
        // SyntaxError!
        // '#foo' needs to be declared before writing to it.
        this.#foo = foo;
    }
}

JavaScript has always allowed users to access undeclared properties, whereas TypeScript has always required declarations for class properties. With private fields, declarations are always needed regardless of whether we’re working in .js or .ts files.

class C {
    /** @type {number} */
    #foo;

    constructor(foo: number) {
        // This works.
        this.#foo = foo;
    }
}

For more information about the implementation, you can check out the original pull request

Which should I use?

We’ve already received many questions on which type of privates you should use as a TypeScript user: most commonly, “should I use the private keyword, or ECMAScript’s hash/pound (#) private fields?”

Like all good questions, the answer is not good: it depends!

When it comes to properties, TypeScript’s private modifiers are fully erased – that means that while the data will be there, nothing is encoded in your JavaScript output about how the property was declared. At runtime, it acts entirely like a normal property. That means that when using the private keyword, privacy is only enforced at compile-time/design-time, and for JavaScript consumers, it’s entirely intent-based.

class C {
    private foo = 10;
}

// This is an error at compile time,
// but when TypeScript outputs .js files,
// it'll run fine and print '10'.
console.log(new C().foo);    // prints '10'
//                  ~~~
// error! Property 'foo' is private and only accessible within class 'C'.

// TypeScript allows this at compile-time
// as a "work-around" to avoid the error.
console.log(new C()["foo"]); // prints '10'

The upside is that this sort of “soft privacy” can help your consumers temporarily work around not having access to some API, and works in any runtime.

On the other hand, ECMAScript’s # privates are completely inaccessible outside of the class.

class C {
    #foo = 10;
}

console.log(new C().#foo); // SyntaxError
//                  ~~~~
// TypeScript reports an error *and*
// this won't work at runtime!

console.log(new C()["#foo"]); // prints undefined
//          ~~~~~~~~~~~~~~~
// TypeScript reports an error under 'noImplicitAny',
// and this prints 'undefined'.

This hard privacy is really useful for strictly ensuring that nobody can take use of any of your internals. If you’re a library author, removing or renaming a private field should never cause a breaking change.

As we mentioned, another benefit is that subclassing can be easier with ECMAScript’s # privates because they really are private. When using ECMAScript # private fields, no subclass ever has to worry about collisions in field naming. When it comes to TypeScript’s private property declarations, users still have to be careful not to trample over properties declared in superclasses.

Finally, something to consider is where you intend for your code to run. TypeScript currently can’t support this feature unless targeting ECMAScript 2015 (ES6) targets or higher. This is because our downleveled implementation uses WeakMaps to enforce privacy, and WeakMaps can’t be polyfilled in a way that doesn’t cause memory leaks. In contrast, TypeScript’s private-declared properties work with all targets – even ECMAScript 3!

Kudos!

It’s worth reiterating how much work went into this feature from our contributors at Bloomberg. They were diligent in taking the time to learn to contribute features to the compiler/language service, and paid close attention to the ECMAScript specification to test that the feature was implemented in compliant manner. They even improved another 3rd party project, CLA Assistant, which made contributing to TypeScript even easier.

We’d like to extend a special thanks to:

export * as ns Syntax

It’s often common to have a single entry-point that exposes all the members of another module as a single member.

import * as utilities from "./utilities.js";
export { utilities };

This is so common that ECMAScript 2020 recently added a new syntax to support this pattern!

export * as utilities from "./utilities.js";

This is a nice quality-of-life improvement to JavaScript, and TypeScript 3.8 implements this syntax. When your module target is earlier than es2020, TypeScript will output something along the lines of the first code snippet.

Special thanks to community member Wenlu Wang (Kingwl) who implemented this feature! For more information, check out the original pull request.

Top-Level await

Most modern environments that provide I/O in JavaScript (like HTTP requests) is asynchronous, and many modern APIs return Promises. While this has a lot of benefits in making operations non-blocking, it makes certain things like loading files or external content surprisingly tedious.

fetch("...")
    .then(response => response.text())
    .then(greeting => { console.log(greeting) });

To avoid .then chains with Promises, JavaScript users often introduced an async function in order to use await, and then immediately called the function after defining it.

async function main() {
    const response = await fetch("...");
    const greeting = await response.text();
    console.log(greeting);
}

main()
    .catch(e => console.error(e))

To avoid introducing an async function, we can use a handy upcoming ECMAScript feature called “top-level await“.

Previously in JavaScript (along with most other languages with a similar feature), await was only allowed within the body of an async function. However, with top-level await, we can use await at the top level of a module.

const response = await fetch("...");
const greeting = await response.text();
console.log(greeting);

// Make sure we're a module
export {};

Note there’s a subtlety: top-level await only works at the top level of a module, and files are only considered modules when TypeScript finds an import or an export. In some basic cases, you might need to write out export {} as some boilerplate to make sure of this.

Top level await may not work in all environments where you might expect at this point. Currently, you can only use top level await when the target compiler option is es2017 or above, and module is esnext or system. Support within several environments and bundlers may be limited or may require enabling experimental support.

For more information on our implementation, you can check out the original pull request.

es2020 for target and module

Thanks to Kagami Sascha Rosylight (saschanaz), TypeScript 3.8 supports es2020 as an option for module and target. This will preserve newer ECMAScript 2020 features like optional chaining, nullish coalescing, export * as ns, and dynamic import(...) syntax. It also means bigint literals now have a stable target below esnext.

JSDoc Property Modifiers

TypeScript 3.8 supports JavaScript files by turning on the allowJs flag, and also supports type-checking those JavaScript files via the checkJs option or by adding a // @ts-check comment to the top of your .js files.

Because JavaScript files don’t have dedicated syntax for type-checking, TypeScript leverages JSDoc. TypeScript 3.8 understands a few new JSDoc tags for properties.

First are the accessibility modifiers: @public, @private, and @protected. These tags work exactly like public, private, and protected respectively work in TypeScript.

// @ts-check

class Foo {
    constructor() {
        /** @private */
        this.stuff = 100;
    }

    printStuff() {
        console.log(this.stuff);
    }
}

new Foo().stuff;
//        ~~~~~
// error! Property 'stuff' is private and only accessible within class 'Foo'.
  • @public is always implied and can be left off, but means that a property can be reached from anywhere.
  • @private means that a property can only be used within the containing class.
  • @protected means that a property can only be used within the containing class, and all derived subclasses, but not on dissimilar instances of the containing class.

Next, we’ve also added the @readonly modifier to ensure that a property is only ever written to during initialization.

// @ts-check

class Foo {
    constructor() {
        /** @readonly */
        this.stuff = 100;
    }

    writeToStuff() {
        this.stuff = 200;
        //   ~~~~~
        // Cannot assign to 'stuff' because it is a read-only property.
    }
}

new Foo().stuff++;
//        ~~~~~
// Cannot assign to 'stuff' because it is a read-only property.

watchOptions

TypeScript has strived to provide reliable file-watching capabilities in --watch mode and in editors for years. While it’s worked well for the most part, it turns out that file-watching in Node.js is hard, and its drawbacks can be reflected in our logic. The built-in APIs in Node.js are either CPU/energy-intensive and inaccurate (fs.watchFile) or they’re wildly inconsistent across platforms (fs.watch). Additionally, it’s practically impossible to determine which API will work better because it depends not only on the platform, but the file system on which a file resides.

This has been a struggle, because TypeScript needs to run on more platforms than just Node.js, and also strives to avoid dependencies to be entirely self-contained. This especially applies to dependencies on native Node.js modules.

Because every project might work better under different strategies, TypeScript 3.8 introduces a new watchOptions field in tsconfig.json and jsconfig.json which allows users to tell the compiler/language service which watching strategies should be used to keep track of files and directories.

{
    // Some typical compiler options
    "compilerOptions": {
        "target": "es2020",
        "moduleResolution": "node",
        // ...
    },

    // NEW: Options for file/directory watching
    "watchOptions": {
        // Use native file system events for files and directories
        "watchFile": "useFsEvents",
        "watchDirectory": "useFsEvents",

        // Poll files for updates more frequently
        // when they're updated a lot.
        "fallbackPolling": "dynamicPriority"
    }
}

watchOptions contains 4 new options that can be configured:

  • watchFile: the strategy for how individual files are watched. This can be set to
    • fixedPollingInterval: Check every file for changes several times a second at a fixed interval.
    • priorityPollingInterval: Check every file for changes several times a second, but use heuristics to check certain types of files less frequently than others.
    • dynamicPriorityPolling: Use a dynamic queue where less-frequently modified files will be checked less often.
    • useFsEvents (the default): Attempt to use the operating system/file system’s native events for file changes.
    • useFsEventsOnParentDirectory: Attempt to use the operating system/file system’s native events to listen for changes on a file’s containing directories. This can use fewer file watchers, but might be less accurate.
  • watchDirectory: the strategy for how entire directory trees are watched under systems that lack recursive file-watching functionality. This can be set to:
    • fixedPollingInterval: Check every directory for changes several times a second at a fixed interval.
    • dynamicPriorityPolling: Use a dynamic queue where less-frequently modified directories will be checked less often.
    • useFsEvents (the default): Attempt to use the operating system/file system’s native events for directory changes.
  • fallbackPolling: when using file system events, this option specifies the polling strategy that gets used when the system runs out of native file watchers and/or doesn’t support native file watchers. This can be set to
    • fixedPollingInterval: (See above.)
    • priorityPollingInterval: (See above.)
    • dynamicPriorityPolling: (See above.)
  • synchronousWatchDirectory: Disable deferred watching on directories. Deferred watching is useful when lots of file changes might occur at once (e.g. a change in node_modules from running npm install), but you might want to disable it with this flag for some less-common setups.

For more information on watchOptions, head over to GitHub to see the pull request.

“Fast and Loose” Incremental Checking

TypeScript’s --watch mode and --incremental mode can help tighten the feedback loop for projects. Turning on --incremental mode makes TypeScript keep track of which files can affect others, and on top of doing that, --watch mode keeps the compiler process open and reuses as much information in memory as possible.

However, for much larger projects, even the dramatic gains in speed that these options afford us isn’t enough. For example, the Visual Studio Code team had built their own build tool around TypeScript called gulp-tsb which would be less accurate in assessing which files needed to be rechecked/rebuilt in its watch mode, and as a result, could provide drastically low build times.

Sacrificing accuracy for build speed, for better or worse, is a tradeoff many are willing to make in the TypeScript/JavaScript world. Lots of users prioritize tightening their iteration time over addressing the errors up-front. As an example, it’s fairly common to build code regardless of the results of type-checking or linting.

TypeScript 3.8 introduces a new compiler option called assumeChangesOnlyAffectDirectDependencies. When this option is enabled, TypeScript will avoid rechecking/rebuilding all truly possibly-affected files, and only recheck/rebuild files that have changed as well as files that directly import them.

For example, consider a file fileD.ts that imports fileC.ts that imports fileB.ts that imports fileA.ts as follows:

fileA.ts <- fileB.ts <- fileC.ts <- fileD.ts

In --watch mode, a change in fileA.ts would typically mean that TypeScript would need to at least re-check fileB.ts, fileC.ts, and fileD.ts. Under assumeChangesOnlyAffectDirectDependencies, a change in fileA.ts means that only fileA.ts and fileB.ts need to be re-checked.

In a codebase like Visual Studio Code, this reduced rebuild times for changes in certain files from about 14 seconds to about 1 second. While we don’t necessarily recommend this option for all codebases, you might be interested if you have an extremely large codebase and are willing to defer full project errors until later (e.g. a dedicated build via a tsconfig.fullbuild.json or in CI).

For more details, you can see the original pull request.

Breaking Changes

TypeScript 3.8 contains a few minor breaking changes that should be noted.

Stricter Assignability Checks to Unions with Index Signatures

Previously, excess properties were unchecked when assigning to unions where any type had an index signature – even if that excess property could never satisfy that index signature. In TypeScript 3.8, the type-checker is stricter, and only “exempts” properties from excess property checks if that property could plausibly satisfy an index signature.

const obj1: { [x: string]: number } | { a: number };

obj1 = { a: 5, c: 'abc' }
//             ~
// Error!
// The type '{ [x: string]: number }' no longer exempts 'c'
// from excess property checks on '{ a: number }'.

let obj2: { [x: string]: number } | { [x: number]: number };

obj2 = { a: 'abc' };
//       ~
// Error!
// The types '{ [x: string]: number }' and '{ [x: number]: number }' no longer exempts 'a'
// from excess property checks against '{ [x: number]: number }',
// and it *is* sort of an excess property because 'a' isn't a numeric property name.
// This one is more subtle.

object in JSDoc is No Longer any Under noImplicitAny

Historically, TypeScript’s support for checking JavaScript has been lax in certain ways in order to provide an approachable experience.

For example, users often used Object in JSDoc to mean, “some object, I dunno what”, we’ve treated it as any.

// @ts-check

/**
 * @param thing {Object} some object, i dunno what
 */
function doSomething(thing) {
    let x = thing.x;
    let y = thing.y;
    thing();
}

This is because treating it as TypeScript’s Object type would end up in code reporting uninteresting errors, since the Object type is an extremely vague type with few capabilities other than methods like toString and valueOf.

However, TypeScript does have a more useful type named object (notice that lowercase o). The object type is more restrictive than Object, in that it rejects all primitive types like string, boolean, and number. Unfortunately, both Object and object were treated as any in JSDoc.

Because object can come in handy and is used significantly less than Object in JSDoc, we’ve removed the special-case behavior in JavaScript files when using noImplicitAny so that in JSDoc, the object type really refers to the non-primitive object type.

What’s Next?

Now that the beta is out, our team has been focusing largely on bug fixes and polish for what will eventually become TypeScript 3.8. As you can see on our current Iteration Plan, we’ll have one release candidate (a pre-release) in a couple of weeks, followed by a full release around mid-February. As editor features we’ve developed become more mature, we’ll also show off functionality like Call Hierarchy and the “convert to template string” refactoring.

If you’re able to give our beta a try, we would highly appreciate your feedback! So download it today, and happy hacking!

– Daniel Rosenwasser and the TypeScript Team

3 comments

Leave a comment

  • Avatar
    Noel Abrahams

    You get an ‘A’ for clarity, Daniel.

    On the `assumeChangesOnlyAffectDirectDependencies`, this looks like something we’d like to use but it’s pretty pointless if there is no easy way to force a full recheck.

    The problem we are having is mostly with ‘compile-on-save’. In our large code-base, it pretty much never works because changes to an upstream file causes hundreds of dependencies to be built and we never get to see the process complete—not in our lifetime.

    It would be great if the flag can be enabled just for `compile-on-save`. So within the context of Visual Studio, invoking a build on a project would check ALL dependencies.

  • Avatar
    Rob Eisenberg

    Sadly, private fields are implemented in a way that make them incompatible with Proxy. If you create a Proxy over an object that uses private fields, then your app will crash with a runtime error when you access *public* APIs that internally use private fields. Practically, this means you can’t use private fields and Proxy together. The implications are that if a library author uses private fields or proxies internally, then that library may be incompatible with other libraries or app code that use private fields or proxies. Ironically, it means that you must know the private implementation details of 3rd party code before you can understand whether it will crash your own code, and if the 3rd party adds private fields in a patch or minor version, it actually constitutes a public API breaking change. After over a year of arguing about this with tc39, they were unwilling to adjust the design to prevent the runtime error.

    I’m grateful that the TypeScript team has been such “good sports” in the midst of this, as I know TS had other concerns with the feature design too. Hopefully, moving forward, we’ll be able to correct for these issues.

    Thanks for the hard work you all do.