January 10, 2020·4 min read·engineeringtypescriptmodulesprivacy

TypeScript 3.8 beta makes erased imports explicit

Type-only imports give single-file compilers an explicit erasure rule. The beta also adds JavaScript private fields, whose runtime protection differs from TypeScript's private modifier.

A compiler processing one file cannot always tell whether a re-exported name exists in JavaScript. The same import syntax can refer to an interface that disappears or a value that must remain. The TypeScript 3.8 beta announced January 10 introduces explicit type-only imports and exports to remove that ambiguity. It also adds private fields with runtime behaviour, making the distinction between erased declarations and JavaScript operations more visible in two places.

I appreciate TypeScript most when it helps make browser application code understandable across a substantial framework ecosystem. Clear rules about which declarations affect the generated program help with that. An import should express enough intent that a build tool can preserve the right runtime behaviour without reconstructing the entire project.

Import elision depends on information

TypeScript normally removes an import that is used only as a type. The beta announcement calls this import elision. Within a complete program, the compiler can know whether a declaration denotes a value, a type, or both. A tool processing a file in isolation has less information.

Consider importing a name and immediately re-exporting it. The file alone does not establish whether its dependency declares an interface or a runtime value. The announcement identifies Babel and TypeScript's transpileModule API as affected by that ambiguity. isolatedModules can warn about the problem, but a warning does not provide the missing expression of intent.

The new forms let the author identify declarations that must disappear. A module exporting an interface can be used as follows, without asking the transpiler to discover that interface's definition elsewhere.

import type { Settings } from "./settings";
export type { Settings };

export function workerCount(settings: Settings): number {
    return settings.workers;
}

This example assumes that settings.ts exports an interface with a numeric workers property. The runtime function remains, while the type import and re-export disappear. The implementation work on type-only declarations includes explicit handling of type-only export declarations in the compiler.

The beta also restricts the import form itself. A type-only declaration can specify a default import or named bindings, but cannot combine both in one statement. That avoids ambiguity about which imported names the type-only marker covers.

A type import does not execute a module

Erasure has a second consequence when an imported module performs initialization. The announcement describes Angular.js services registered as a module side effect. If a consumer imports the service only to annotate a parameter, ordinary import elision can remove the import and prevent the registration code from executing.

import type makes erasure explicit, but it does not solve that initialization requirement by executing the module anyway. A side-effect import remains a separate statement. Code that needs registration must retain a runtime dependency on the registration module.

import type { Service } from "./service";
import "./service";

export function describe(service: Service): string {
    return service.name;
}

Here the two statements describe separate purposes. The first supplies a declaration for checking, assuming the exported service type has a string name property. The second requires module execution. Combining those purposes through an ordinary import used only in an annotation leaves the runtime dependency less explicit.

The beta has a specific type-only limitation

Classes have both a type meaning and a value meaning. This beta imports only the type meaning through import type. Extending an imported class in executable code therefore fails, because creating the derived class requires its base constructor at runtime.

The announcement also describes restrictions in erased positions such as a typeof type query and the base of a declared class. It discusses changing those rules after feedback, but that change is not part of this beta. Examples for the beta must respect the implementation being offered now.

This is an important qualification for evaluating a preview. The basic erasure guarantee is clear, while some permitted uses of an imported declaration remain under discussion. Treating the intended later behaviour as already implemented would give a misleading account of what users can test today.

Private fields affect the generated program

The beta's # private fields have a different relationship to runtime JavaScript. TypeScript's existing private modifier supplies a checking restriction that disappears from emitted code. The property itself remains an ordinary property. A # field enforces privacy for JavaScript consumers as well.

The announcement also explains that a private name belongs to its declaring class. A base class and subclass can each declare #value without those fields colliding. Access on an object that lacks the required private field fails with a TypeError, so matching a public-looking property name is insufficient.

I prefer choosing between these mechanisms by their actual contract. A checking-only restriction can permit deliberate escape routes, while a private field prevents ordinary external property access. They are different library design choices even when both express that some state is internal.

The beta requires an ES2015 or newer target for its private-field implementation because the downlevel code uses WeakMaps. That target requirement is separate from type-import erasure, which leaves no runtime import at all. The release gives authors more explicit ways to state both kinds of intent, with different consequences for the JavaScript that consumers execute.