Esteban Herrera Family man, Java and JavaScript developer. Swift and VR/AR hobbyist. Like books, movies, and still trying many things. eherrera.net

What’s new in TypeScript 3.8

6 min read 1892

Typescript 3.8 was released on February 20th, 2020. This version includes changes to the compiler, performance, and editor.

In this post, I’m going to review five important changes to the compiler:

  • Type-only imports and exports
  • export * as ns syntax
  • ES2020 private fields
  • Top-level await
  • JSDoc property modifiers

At the time of this writing, version 3.8.3 is already out. So first, upgrade to the latest version:

  • You can upgrade by using NPM, with commands like npm install typescript@latest or npm -g upgrade typescript (add the -g option if you’re using it globally)
  • If you’re using Visual Studio, you can do so by downloading it here
  • If you’re using Visual Studio Code, you can upgrade by modifying either the user settings or the workspace settings
  • If you’re using Sublime Text, you can upgrade via PackageControl

In a terminal window, you can use the following command to confirm you’re using the latest version:

tsc --version

Now let’s start by reviewing the type-only import/export feature.

Type-only import and exports

This feature brings new syntax to import and export declarations, along with a new compiler option, importsNotUsedAsValues:

import type { MyType } from "./my-module.js";
// ...
export type { MyType };

This gives you more control over how import statements are handled in the output, which is particularly useful when compiling using the --isolatedModules option, the transpileModule API, or Babel.

By default, TypeScript drops import statements when the imports are only used as types. For example, consider the following:

// lion.ts
export default class Lion {}

// zoo.ts
import Lion from './lion';
let myLion: Lion;

This will be the output when you compile zoo.ts:

// zoo.js
let myLion: Lion;

Usually, this won’t be a problem. But what if there’s a side-effect in the Lion module:

We made a custom demo for .
No really. Click here to check it out.

// lion.ts
export default class Lion {}
console.log("Here's an important message about lions: ... ");

In this case, if the import statement is dropped from the output, the console.log statement will never be executed.

The import statement will also be dropped if you declare it like this:

import {TypeA, Type2} from "./my-module";

However, it will be kept in the output if you declare it like this:

import "./my-module";

A bit confusing, right?

Here’s another problem. In the following code, can you tell if X is a value or a type?

import { X } from "./my-module.js";

export { X };

Knowing this might be important, Babel and TypeScript’s transpileModule API will output code that doesn’t work correctly if X is only a type, and TypeScript’s isolatedModules flag will generate a warning.

On the other hand, we can have a similar problem with exports, where a re-export of a type should be omitted but the compiler can’t tell that we’re just re-exporting a type during single-file Babel transpilation, for example.

In TypeScript 3.8, import type and export type make explicit the importing/exporting of types.

These are some valid ways of using them:

import type MyType from './my-module';
import type { MyTypeA, MyTypeB } from './my-module';
import type * as Types from './my-module';

export type { MyType };
export type { MyType } from './module';

And here are some invalid ways:

import { type MyType } from './my-module';
import type MyType, { FunctionA } from './my-module';

export { FunctionA, type MyType } from './my-module';

Keep in mind that if the type is not used as a type, the compiler will mark this as an error:

import type Lion from './lion';
let myLion: Lion; // Valid
myLion = new Lion(); // Invalid: 'Lion' cannot be used as a value because it was imported using 'import type'.

When using import type, the behavior is to drop the import declaration from the JavaScript file, as usual. But in TypeScript 3.8, when using import, the behavior can be controlled with the compiler option importsNotUsedAsValue, which can take the values:

  • default, to omit the import declaration
  • preserve, to keep the import declaration, useful to execute side-effects
  • error, like preserve but adds an error whenever an import could be written as an import type

This way, by adding the option "importsNotUsedAsValue":"preserve" to the tsconfig.json file:

// tsconfig.json
{
    "compilerOptions": {
      // ...
      "importsNotUsedAsValues": "preserve"
    },
    // ...
}

This TypeScript code:

import Lion from './lion';
let myLion: Lion;

Compiles to this JavaScript code:

import './Lion';
let myLion;

Export * as ns syntax

TypeScript supports some of the newer ECMAScript 2020 features, such as export * as namespace declarations.

Sometimes, it’s useful to have something like this:

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

Which exposes all the members of another module as a single member.

In ES2020, this can be expressed as one statement:

export * as animals from "./animals";

TypeScript 3.8 supports this syntax.

If you configure the module for ES2020:

// tsconfig.json
{
    "compilerOptions": {
      "module": "ES2020",
      // ...
    }
}

TypeScript will output the statement without modifications:

// allAnimals.ts
export * as animals from "./animals";

// allAnimals.js
export * as animals from "./animals";

But if you configure the module with something earlier, for example:

// tsconfig.json
{
    "compilerOptions": {
      "module": "ES2015",
      // ...
    }
}

TypeScript will output these two declarations:

import * as animals_1 from "./animals";
export { animals_1 as animals };

ES2020 private fields

ES2020 also brings a new syntax for private fields. Here’s an example:

class Lion {
    #age: number;
    constructor(age: number) {
        this.#age = age;
    }
    getAge() {
        return this.#age;
    }
}

Private fields start with the # character, and just like fields marked with the private keyword, they are scoped to their containing class.

Why the # character? Well, apparently all the other cool characters were already taken or could lead to invalid code.

However, there are some rules.

First of all, you cannot use the private modifier and the # character on the same field at the same time (or the public modifier, although the combination wouldn’t make sense anyway).

Does this mean that the private modifier is going to disappear eventually?

At the time of this writing, there’s an open discussion about this, but the current plan is to leave it as it is.

So, which one should you use?

Well, it depends on how strict you want to be about privacy.

The thing with the private modifier is that it’s only recognized by TypeScript, which means that the access restriction is only enforced at compile-time and the private constraint will get erased from the generated JavaScript code, where the private field can be accessed without problems.

On the other hand, the # character will be preserved in the JavaScript code, making the field completely inaccessible outside of the class.

Another rule when using  #  is that private fields always have to be declared before they’re used:

class Lion {
    constructor(age: number) {
          // Error: Property '#age' does not exist on type 'Lion'.ts
          this.#age = age;
    }
}

Also, notice how you have to reference the private field with this, otherwise, an error will be marked:

class Lion {
    #age: number;
    // ...
    getAge() {
        // The following line throws two errors:
        // 1. Private identifiers are not allowed outside class bodies.
        // 2. Cannot find name '#age'
        return #age;
    }
}

In order to use fields marked with #, you must target ECMAScript 2015 (ES6) or higher:

// tsconfig.json
{
    "compilerOptions": {
      "target": "ES6",
      // ...
    }
}

The reason is that the implementation to enforce privacy uses WeakMaps, which can’t be polyfilled in a way that doesn’t cause memory leaks, not all runtimes optimize the use of WeakMaps. In contrast, fields with the private modifier work with all targets and are faster.

For example, this TypeScript class:

// lion.ts
class Lion {
    #age: number;
    constructor(age: number) {
        this.#age = age;
    }
    getAge() {
        return this.#age;
    }
}

Outputs this:

// lion.js
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, privateMap, value) {
    if (!privateMap.has(receiver)) {
        throw new TypeError("attempted to set private field on non-instance");
    }
    privateMap.set(receiver, value);
    return value;
};
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, privateMap) {
    if (!privateMap.has(receiver)) {
        throw new TypeError("attempted to get private field on non-instance");
    }
    return privateMap.get(receiver);
};
var _age;
class Lion {
    constructor(age) {
        _age.set(this, void 0);
        __classPrivateFieldSet(this, _age, age);
    }
    getAge() {
        return __classPrivateFieldGet(this, _age);
    }
}
_age = new WeakMap();

And from the output of the following code, you can see the age property cannot be seen directly outside its class:

const lion = new Lion(5);
console.log(lion); // 'Lion {}'
console.log(Object.keys(lion)); // '[]'
console.log(lion.getAge()); // 5

Top-level await

In JavaScript, we can use async functions with await expressions to perform asynchronous operations:

async function getLionInformation() {
    let response = await fetch('/animals/lion.json');
    let lion = await response.json();
    return lion;
}

getLionInformation().then((value) => console.log(value));

But await expressions are only allowed in the body of async functions, we cannot use them in top-level code (which can be useful when using the developer console on Chrome, for example):

// Syntax error
let response = await fetch('/animals/lion.json');
let lion = await response.json();
console.log(lion);

However, top-level await (at this time a Stage 3 proposal for ECMAScript) allows us to use await directly at the top level of a module or a script.

TypeScript 3.8 supports top-level await, and since files with import and export expressions are considered modules, even a simple export {} would be enough to make this syntax work:

let response = await fetch('/animals/lion.json');
let lion = await response.json();
console.log(lion);

export {}

The only restrictions in TypeScript are that:

  • The target compiler option must be es2017 or above,
  • The module compiler option must be esnext or system

JSDoc property modifiers

JSDoc allows us to add documentation comments directly to JavaScript source code so the JSDoc tool can scan the code and generate an HTML documentation website.

But more than for documentation purposes, TypeScript uses JSDoc for type-checking JavaScript files.

This is possible due to two compiler options:

  • allowJs, which allows TypeScript to compile JavaScript files
  • checkJs, which is used in conjunction with the option above and allows TypeScript to report errors in JavaScript files

TypeScript 3.8 adds support for three accessibility modifiers:

  • @public, which means that the property can be used from anywhere (the default behavior)
  • @private, which means that a property can only be used within the class that defines it
  • @protected, which means that a property can only be used within the class that defines it and all the derived subclasses

For example:

// lion.js
// @ts-check
class Lion {
    constructor() {
        /** @private */
        this.age = 5;
    }
}

// Error: Property 'age' is private and only accessible within class 'Lion'.
console.log(new Lion().age);

And the @readonly modifier, which ensures that a property is only ever assigned a value during initialization:

// lion.js
// @ts-check
class Lion {
    constructor(ageParam) {
        /** @readonly */
        this.age = ageParam;
    }
    setAge(ageParam) {
        // Error: Cannot assign to 'age' because it is a read-only property
        this.age = ageParam;
    }
}

You can learn more about type checking JavaScript files here, along with the supported JSDoc tags.

Conclusion

In this post, you have learned about five new features in TypeScript 3.8, type-only imports and exports, the export * as ns syntax, ES2020 private fields, top-level await, and JSDoc property modifiers.

Of course, there are more new features.

For the compiler:

  • Better directory watching on Linux and watchOptions (more info here)
  • “Fast and Loose” incremental checking (more info here)

For the editor (more information here):

And some breaking changes:

  • Stricter assignability checks to unions with index signatures
  • Optional arguments with no inferences are correctly marked as implicitly any
  • object in JSDoc is no longer any under noImplicitAny

You can find more information about these breaking changes here.

Happy coding!

: Full visibility into your web apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

.
Esteban Herrera Family man, Java and JavaScript developer. Swift and VR/AR hobbyist. Like books, movies, and still trying many things. eherrera.net

Leave a Reply