Description
At present, the only way to declare a property whose key is a well-known symbol is via the global Symbol
constructor. This approach creates a number of challenges, especially for authors of libraries. Notably, it complicates the use of imported polyfills and makes it difficult to describe libraries compatible with multiple targets. This proposal suggests a solution: a literal notation for well-known symbols.
A well-known-symbol literal is a way of referring to a specific well-known symbol without referring to the global Symbol
constructor or any other declaration.
@@iterator // Literal notation for Symbol.iterator
@@toStringTag // Literal notation for Symbol.toStringTag
The literal form of a well-known symbol may be used as both (1) a type and as (2) an abstract property key. It may not be used as a value.
interface SymbolConstructor {
iterator: @@iterator; // As a type
}
interface Iterable<T> {
@@iterator(): Iterator<T>; // As an abstract property key
}
let iterator = @@iterator; // TSError: @@iterator is not a value
While there has been some discussion of stronger type checking for symbols in general (#2012, #5579, #7436), this proposal focuses on the special case of well-known symbols. I believe well-known symbols deserve separate consideration, both because
- well-known symbols raise unique challenges (see Challenges with ES6 symbols #2012 for a list of challenges raised by user-defined symbols, none of which applies here), and
- the solution presented here is likely to be more straightforward to implement than any general proposal for symbol literals.
The advantages of this proposal include
- that it is backward-compatible with existing syntax,
- that it cannot conflict with existing user-defined types or properties, and
- that it does not alter code emission.
The Problem
The current approach to well-known symbols creates a number of challenges, especially for authors of libraries intended to be compatible with multiple ECMAScript versions. Two reasons for these challenges are
- That library authors typically import a
Symbol
polyfill if one is needed rather than expose one globally, and - That authors of libraries often don't know what the consumer's target will be and whether a global
Symbol
declaration will exist
I discuss each problem in turn.
Importing a Symbol polyfill
Application authors who need a Symbol
polyfill usually introduce one globally; this case is unproblematic in TypeScript, as the polyfill can be used just as if it were the native Symbol
constructor. Library authors – as a best practice – typically import a polyfill so as not to pollute the global namespace; this causes problems in TypeScript, as the compiler requires that well-known symbols be referenced as properties of the global Symbol
constructor (#8099, #8169).
Consider the following example:
import Symbol = require('core-js/library/es6/symbol');
export class Range implements Iterable<number> {
[Symbol.iterator]() {/* ... */}
}
This kind of case is common enough when writing libraries that utilize the ES2015 iteration protocols in an ES5-compatible way. But TypeScript won't accept it; it throws the following compiler error:
Error TS2470: 'Symbol' reference does not refer to the global Symbol constructor object
The error appears even though the imported Symbol
object will be the global Symbol
constructor if it exists in the runtime environment.
Describing a library when the consumer's configuration is unknown
Library authors often write APIs compatible with both ES5 and ES2015 and above. Consider a sum()
function that accepts a sequence of numbers and returns the total. The sequence may be either (1) an array-like object or (2) an iterable object. An attempt at declaring such a function might look like this:
export function sum(values: ArrayLike<number>): number;
export function sum(values: Iterable<number>): number;
This works fine in ES2015 and above. But in ES5, there is a problem. Since the Iterable
interface does not exist, it is interpreted as any
. And thus the benefits of static typing are lost.
import { sum } from 'my-math-lib';
sum([2, 3]); // OK
sum(/abc/g); // OK? (This should throw a compiler error, but it doesn't when targeting ES5)
There is an imperfect solution to this problem. First, the author has to recreate the Iterable
interface in case one is not available globally to the consumer.
export interface Iterable<T> {
[Symbol.iterator](): Iterator<T>; // Iterator interface omitted for brevity
}
But this generates the same error we saw above if no global Symbol
declaration is present.
Error TS2470: 'Symbol' reference does not refer to the global Symbol constructor object
The solution to this involves recreating the SymbolConstructor
interface as well and declaring a global Symbol
object (dojo/core#149). Not only is this solution convoluted, but it also pollutes the consumer's global declarations with an object that may not exist at runtime.
The Proposed Solution
There are no good solutions to the above problems at present. The solution I propose is that a literal notation for well-known symbols be added to the language. Informally, the literal notation for a well-known symbol is just a way to refer to that symbol without relying on the global Symbol
constructor or any other declared object. A more formal description follows.
Well-known-symbol literal
A well-known-symbol literal has the following characteristics:
- It is a reference to a particular well-known symbol
- It is referred to by its specification name (e.g.,
@@iterable
,@@toStringTag
) - It is available regardless of a project's target or included declaration libraries (in the same way the
symbol
type is available) - It may be used either as (1) a type or (2) an abstract property key
- It is a subtype of
symbol
when used as a type
As a type
A variable may be declared as a well-known symbol like so:
let iterator: @@iterator;
Type inference for well-known symbols is analogous to type inference for string literals:
let iterator = Symbol.iterator; // iterator: symbol
const ITERATOR = Symbol.iterator; // ITERATOR: @@iterator
Any value whose type is a well-known symbol may be used as a computed property in place of its corresponding property on the global Symbol
constructor.
const ITERATOR = Symbol.iterator; // ITERATOR: @@iterator (inferred)
export class Range implements Iterable<number> {
[ITERATOR]() {/* ... */} // Equivalent to using [Symbol.iterator] directly
}
export interface Iterable<T> {
[ITERATOR](): Iterator<T>; // Equivalent to using [Symbol.iterator] as the property key
}
As an abstract property key
We can use literal notation on an interface to declare a property whose key is a well-known symbol.
export interface Iterable<T> {
@@iterator(): Iterator<T>; // Equivalent to using [Symbol.iterator] or another value of type @@iterator
}
Literal notation may also be used as an abstract property key of an abstract class.
export abstract class AbstractIterable<T> {
abstract @@iterator(): Iterator<T>;
}
Importantly, the property must be abstract when using literal notation as a property key. Since there is no such literal notation in JavaScript, the author must supply an actual value as a computed property when implementing methods and properties whose keys are well-known symbols. It would not make sense, for example, to allow @@iterator
to be used as an alias for Symbol.iterator
, as the whole point of the literal notation is that we can use it without relying on the presence of the Symbol
constructor.
Usage
We can use literal notation to solve both of the problems identified above.
Solving the polyfill problem
To solve the first problem, the imported Symbol
polyfill simply has to have an 'iterator'
property of type @@iterator
rather than symbol
. A partial declaration file might look like this:
declare module 'core-js/library/es6/symbol' {
interface SymbolConstructor {
iterator: @@iterator; // Instead of `iterator: symbol`
}
const Symbol: SymbolConstructor;
export = Symbol;
}
And now we can use the local Symbol.iterator
as a computed property of an iterable object:
import Symbol = require('core-js/library/es6/symbol');
export class Range implements Iterable<number> {
[Symbol.iterator]() { /* ... */ } // This works, as Symbol.iterator is of type @@iterator
}
Solving the multi-target library problem
To solve the second problem, the author must still write her own Iterable
interface. But she need not describe or rely on a global Symbol
constructor declaration.
export interface Iterable<T> {
@@iterator(): Iterator<T>; // Iterator interface omitted for brevity
}
export function sum(values: ArrayLike<number>): number;
export function sum(values: Iterable<number>): number;
Now type-checking is consistent irrespective of the existence of a global Symbol
constructor declaration.
import { sum } from 'my-math-lib';
sum([2, 3]); // OK
sum(/abc/g); // Error
The compiler throws the expected error regardless of the consumer's configuration:
Error TS2345: Argument of type 'RegExp' is not assignable to parameter of type 'Iterable<number>'