Skip to content

Proposal: a literal notation for well-known symbols #13031

Closed
@mcmath

Description

@mcmath

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

  1. 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
  2. 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

  1. that it is backward-compatible with existing syntax,
  2. that it cannot conflict with existing user-defined types or properties, and
  3. 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

  1. That library authors typically import a Symbol polyfill if one is needed rather than expose one globally, and
  2. 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>'

Metadata

Metadata

Assignees

No one assigned

    Labels

    DeclinedThe issue was declined as something which matches the TypeScript visionSuggestionAn idea for TypeScript

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions