-
Notifications
You must be signed in to change notification settings - Fork 13.1k
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.toStringTagThe 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 valueWhile 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
Symbolpolyfill 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
Symboldeclaration 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
symboltype is available) - It may be used either as (1) a type or (2) an abstract property key
- It is a subtype of
symbolwhen 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: @@iteratorAny 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); // ErrorThe 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>'