February 20, 2020·6 min read·engineeringtypescriptprivacymodulescompilation

TypeScript 3.8 gives private fields a runtime contract

ECMAScript private fields protect state after TypeScript has emitted JavaScript. Their inheritance behaviour and WeakMap-based output distinguish them from the existing private modifier.

A private property in TypeScript has normally remained an ordinary JavaScript property after compilation. The checker prevents certain accesses, but the emitted object does not remember that restriction. TypeScript 3.8, released February 20, adds ECMAScript private fields, which enforce their access rules in the running program. That changes the contract between a library and JavaScript consumers who never pass through its TypeScript checks.

The release supports the stage-three class-fields proposal and credits contributors from Bloomberg with implementing the feature. Applications face a more specific design choice than selecting a newer privacy spelling. The existing private modifier and the new # names protect different boundaries, and the emitted code reflects that difference.

I value TypeScript's ecosystem for building web applications, even though I remain circumspect about the language and its origin. This is a useful feature because its promise can be expressed in terms of JavaScript behaviour. The access restriction continues to exist after type checking has finished.

The existing modifier protects checked callers

The announcement describes TypeScript's private modifier as erased. The property's value remains on the instance as a normal property. A JavaScript caller can consequently access it through an ordinary property lookup even though TypeScript reports a direct access as an error.

That design has practical flexibility. A consumer can sometimes work around a missing public operation by accessing an implementation property. The release discussion explicitly recognises that possibility. It also means the library cannot treat compile-time privacy as proof that consumers never depend on its internal property names.

An internal rename may therefore have an effect outside the set of valid TypeScript callers. The checker can express which accesses the author supports, but it cannot retroactively restrict JavaScript that reads the emitted object's ordinary properties. That is the boundary the modifier actually supplies.

The new private-field syntax provides another option. A field name beginning with # belongs to its containing class. The announcement explains that TypeScript accessibility modifiers cannot be attached to those names. The JavaScript mechanism supplies the privacy instead of combining with private to make an ordinary property more restricted.

A private name is scoped to its class

Inheritance makes the difference visible even when no external caller attempts to inspect an object. An ordinary property name refers to the same property through base and derived methods. If the derived constructor initializes that property again, a base method sees the changed value.

Private names have a distinct identity in each class. A base class and a subclass can both declare #value without referring to the same field. The following example follows the inheritance behaviour described in the release announcement.

class BaseCounter {
    #value = 10;

    baseValue(): number {
        return this.#value;
    }
}

class DerivedCounter extends BaseCounter {
    #value = 20;

    derivedValue(): number {
        return this.#value;
    }
}

const counter = new DerivedCounter();
console.assert(counter.baseValue() === 10);
console.assert(counter.derivedValue() === 20);

The two methods use the same written field name but resolve it in different declaring classes. A subclass does not overwrite the base class's private state merely by choosing that spelling. This is a concrete benefit for library evolution, where subclasses can otherwise accidentally reuse an implementation property's name.

The mechanism also changes what a caller can fake with a plain object. The announcement demonstrates accessing a private field on an argument typed as any. An object with a similarly named public property does not satisfy the private-field requirement. The access fails at runtime with TypeError.

That failure should be understood as a check on the field's presence for the required class context. It is not a general validation of every property or invariant of the object. A private field supplies one access rule, while the class's public methods still determine what operations are allowed on its state.

The downlevel output stores fields in WeakMaps

The compiler's private-field output baseline shows how an ES2015-targeted example preserves the rule. The emitted code creates a WeakMap for the field and stores the value using the instance as the key. Reading the field goes through a helper.

The helper first calls has on the private map for the supplied receiver. If the receiver is absent, it throws TypeError. Otherwise it retrieves the value through get and returns that value to the calling method. The emitted operation therefore retains information that an ordinary property access would not carry.

This explains the release's target restriction. TypeScript 3.8 supports these private fields when targeting ES2015 or newer because its downlevel implementation depends on WeakMaps. The announcement says WeakMaps cannot be polyfilled in a way that avoids memory leaks, so this implementation does not extend to the oldest targets supported by the existing private modifier.

The runtime cost also differs. The release discussion warns that WeakMap-backed accesses can be slower than ordinary property accesses, depending on the runtime. It does not supply a universal performance ratio. The semantic difference is established by the generated operations, while their cost depends on the implementation executing them.

I prefer that tradeoff stated in terms of the required contract. If a library needs runtime privacy and independence from subclass field naming, the extra mechanism has a purpose. A checking-only restriction can remain appropriate when ordinary property behaviour and older targets are part of the intended interface.

Declarations remain necessary in JavaScript too

The announcement notes another consequence for plain JavaScript files. A private field has to be declared before assignment. Assigning this.#value without declaring the private name is a syntax error. That differs from assigning a new ordinary property inside a constructor.

This requirement makes the class's private names explicit in its definition. It also means that a JavaScript consumer or author cannot treat the new syntax as an unrestricted property name with punctuation. The parser and runtime attach class-specific meaning to it.

Bracket access does not provide the same escape route as an ordinary property. Looking up the string "#value" requests an ordinary string-named property. It does not refer to the private name declared by the class. The announcement illustrates the distinction with an undefined result for such a lookup.

The consequence for maintenance is narrower than saying private state can never cause a breaking change. Changing a public method's behaviour can still affect callers. What becomes independent of callers' ordinary property accesses is the private field's name and storage. A library author can reason about those implementation details without supporting direct access through a string key.

Type-only imports specify the opposite runtime outcome

The same release adds import type and export type, which always disappear from JavaScript output. These forms address ambiguity for tools compiling one file at a time. A re-exported name may denote only a type, and a single-file transpiler cannot establish that by reading a different module's declaration.

An explicit type-only import supplies that missing intent. It does not execute a module for side effects. Code needing module initialization must retain a runtime import separately. The announcement's service-registration example shows why importing a class only for annotation can otherwise remove behaviour that the application needs.

Classes again require care because they exist as both types and values. Extending a class in executable code needs its constructor value. Importing it with import type cannot supply that runtime dependency. The syntax communicates erasure, so it cannot serve as a substitute for an ordinary import when inheritance needs code.

The importsNotUsedAsValues option makes the policy for ordinary imports configurable. Its default removes imports used only as types. The preserve mode retains their module side effects, while the error mode also reports value imports used only as types. These choices let a project express how aggressively it wants the distinction checked.

Compiler support and runtime support remain separate

Top-level await, another feature in this release, also has target and module requirements. The announcement requires at least an ES2017 target and an esnext or system module setting. The file must be a module, which may require an explicit empty export when it has no other imports or exports.

Those requirements reinforce a practical distinction across the release. The compiler can accept source syntax while its output still requires particular runtime or loader capabilities. Private fields need the emitted privacy mechanism, and top-level await needs compatible module handling. Type-only imports instead promise that the declaration has no runtime remnant.

For a library exposing JavaScript to several kinds of consumers, each feature therefore needs its own compatibility account. A # field is protected in the emitted implementation and scoped to its class. A type-only import is erased. The existing private modifier continues to restrict checked source while leaving an ordinary property in the generated program.