Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
mcmath opened this issue Dec 19, 2016 · 9 comments · Fixed by #15473
Closed

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

mcmath opened this issue Dec 19, 2016 · 9 comments · Fixed by #15473
Labels
Declined The issue was declined as something which matches the TypeScript vision Suggestion An idea for TypeScript

Comments

@mcmath
Copy link

mcmath commented Dec 19, 2016

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>'
@RyanCavanaugh RyanCavanaugh added In Discussion Not yet reached consensus Suggestion An idea for TypeScript labels Dec 21, 2016
@acutmore
Copy link
Contributor

acutmore commented Jan 1, 2017

Libraries that wish to refer to Symbol and compile to ES3 or ES5 can still be compiled by adding the definition explicitly:

interface Iterable<T> {
  [Symbol.iterator](): Iterator<T>;
}

compiles with:

tsc --lib ES2015.Symbol,ScriptHost,ES5,ES2015.Iterable --target ES5 src.ts

@mcmath
Copy link
Author

mcmath commented Jan 2, 2017

@acutmore The problem is not about compiling the library itself. It's about the type declarations for a library whose consumer may be targeting ES3 or ES5.

If I install a library whose declarations include the Iterable<T> interface, and yet there is not global Symbol constructor declared, I get the following error when I try to consume the library:

Error TS2470: 'Symbol' reference does not refer to the global Symbol constructor object

Now, as a consumer of that library, I could include the ES2015.Symbol declaration library, as you suggest. But this is not ideal, as

  1. It exposes a global Symbol constructor declaration that may not even exist at runtime
  2. It imposes an extra burden on consumer that they are likely only to realize after getting the above error and searching the Issues section of the library's repository
  3. The consumer may not even want to use the Iterable<T> interface, as is likely if they are targeting anything less than ES2015

@acutmore
Copy link
Contributor

acutmore commented Jan 2, 2017

@akim-mcmath So the issue is that ideally it would only be a compile-time error if Symbol was accessed rather than just referred to in an interface?

interface I {
  [Symbol.iterator](): any; // no compiler error
}

var s = Symbol.iterator; // compile time error

Maybe this could be solved more generally. If the issue is libraries wanting to support features (like symbols or other things like generators) but only if the consumer specifies that they are available at runtime then there could be conditional compilation. Similar to c#, c++ #4691

interface Foo {
   bar(): void;
#if symbol
   [Symbol.iterator](): Iterator;
# endif
}

Or libraries offer multiple versions for use with different targets?

@mcmath
Copy link
Author

mcmath commented Jan 5, 2017

@acutmore Yes, I thought about conditional compilation too. That would solve the second of the two problems I identify. And it could be used more generally for other purposes.

I also considered the possibility of having different versions based on the consumer's target. This would essentially be an extension of the way setting the --target compiler option changes the default libraries that are included.

But neither would solve the first problem, i.e., there would still be no way of importing a polyfill and using its properties to refer to well-known symbols. A literal notation for well-known symbols would solve both of these problems. Plus, it would be consistent with other literal types that have been added to TypeScript – first string literals, and now literals for numbers, booleans, null, and undefined.

And if either of these alternative suggestions were ever implemented, it would in no way conflict with a well-known-symbol literal type. I actually think that both solutions are preferable for addressing the second problem, but I can't think of a better way to address the first than the proposal I outline above.

@mhegazy
Copy link
Contributor

mhegazy commented Jan 6, 2017

Just a quick note. These proposed changes are gated on #5579.

@laughinghan
Copy link

Any update on this? I ran into this with @types/acorn (which I don't even depend on, one of my dependencies transitively depends on it), and I'd love to not leak Symbol to my ES3-targeting application: DefinitelyTyped/DefinitelyTyped#24595

@saschanaz
Copy link
Contributor

@types/node exposes Symbol, Iterator, etc.:

// Forward-declare needed types from lib.es2015.d.ts (in case users are using `--lib es5`)
interface Iterable<T> { }
interface Iterator<T> {
    next(value?: any): IteratorResult<T>;
}
interface IteratorResult<T> { }
interface SymbolConstructor {
    readonly iterator: symbol;
}
declare var Symbol: SymbolConstructor;

lib.d.ts exposes Promise type for ES5 DOM use, we may need a similar approach to Symbols.

@saschanaz
Copy link
Contributor

saschanaz commented Apr 10, 2018

Proposal 1. symbol.iterator

// Internally define well-known symbol.iterator etc. as unique symbols:

interface SymbolConstructor {
  readonly iterator: symbol.iterator;
}

// Expose Iterator, IterableIterator, etc. by default
interface Headers {
  // allow unique symbols in index signature
  [key: symbol.iterator](): IterableIterator<[string, string]>;
}
  1. Optional type resolve
interface Headers {
  // Ignore the signature when type resolving fails
  /** @skipTypeCheck */
  [Symbol.iterator](): IterableIterator<[string, string]>;
}

hausdorff added a commit to pulumi/pulumi-query that referenced this issue Jun 1, 2019
Older versions of the ES standard do not define `Symbol#asyncIterator`.
This means our attempts to "manually" define `AsyncIterator` for
versions of ES that don't have it fail.

Following [1] and [2], our understanding is that the best existing
solution is to manually re-define `SymbolConstructor` to have this
member.

[1]: microsoft/TypeScript#13031
[2]: microsoft/TypeScript#8099
hausdorff added a commit to pulumi/pulumi-query that referenced this issue Jun 3, 2019
Older versions of the ES standard do not define `Symbol#asyncIterator`.
This means our attempts to "manually" define `AsyncIterator` for
versions of ES that don't have it fail.

Following [1] and [2], our understanding is that the best existing
solution is to manually re-define `SymbolConstructor` to have this
member.

[1]: microsoft/TypeScript#13031
[2]: microsoft/TypeScript#8099
hausdorff added a commit to pulumi/pulumi-query that referenced this issue Jun 3, 2019
Older versions of the ES standard do not define `Symbol#asyncIterator`.
This means our attempts to "manually" define `AsyncIterator` for
versions of ES that don't have it fail.

Following [1] and [2], our understanding is that the best existing
solution is to manually re-define `SymbolConstructor` to have this
member.

[1]: microsoft/TypeScript#13031
[2]: microsoft/TypeScript#8099
@RyanCavanaugh RyanCavanaugh added Declined The issue was declined as something which matches the TypeScript vision and removed In Discussion Not yet reached consensus labels Jun 24, 2021
@RyanCavanaugh
Copy link
Member

This doesn't seem to come up often enough to justify adding a new syntax form, and will come up less often in the future as ES5 runtimes retire to their final resting places. You can write this today, which works in any target:

declare const SymbolShim: SymbolConstructor;
interface SymbolConstructor {
    readonly iterator: unique symbol;
}
interface foo {
    [SymbolShim.iterator](): any;
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Declined The issue was declined as something which matches the TypeScript vision Suggestion An idea for TypeScript
Projects
None yet
Development

Successfully merging a pull request may close this issue.

7 participants