microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.74k stars 12.46k forks source link

Allow indexing with symbols #1863

Closed wereHamster closed 3 years ago

wereHamster commented 9 years ago

TypeScript now has a ES6 target mode which includes definitions Symbol. However when trying to index an object with a symbol, I get an error (An index expression argument must be of type 'string', 'number', or 'any').

var theAnswer = Symbol('secret');
var obj = {};
obj[theAnswer] = 42; // Currently error, but should be allowed
mhegazy commented 9 years ago

That is part of the ES6 Symbol support we @JsonFreeman is working on. Your code sample should be supported in the next release.

JsonFreeman commented 9 years ago

@wereHamster, with pull request #1978, this should become legal, and obj[theAnswer] will have type any. Is that sufficient for what you are looking for, or do you need stronger typing?

wereHamster commented 9 years ago

Will it be possible to specify the type of properties which are indexed by symbols? Something like the following:

var theAnswer = Symbol('secret');
interface DeepThought {
   [theAnswer]: number;
}
danquirk commented 9 years ago

Based on the comments in that PR, no:

This does not cover symbol indexers, which allows an object to act as a map with arbitrary symbol keys.

JsonFreeman commented 9 years ago

I think @wereHamster is talking about a stronger typing than @danquirk. There are 3 levels of support here. The most basic level is provided by my PR, but that is just for symbols that are properties of the global Symbol object, not user defined symbols. So,

var theAnswer = Symbol('secret');
interface DeepThought {
    [Symbol.toStringTag](): string; // Allowed
    [theAnswer]: number; // not allowed
}

The next level of support would be to allow a symbol indexer:

var theAnswer = Symbol('secret');
interface DeepThought {
   [s: symbol]: number;
}
var d: DeepThought;
d[theAnswer] = 42; // Typed as number

This is on our radar, and can be implemented easily.

The strongest level is what you're asking for, which is something like:

var theAnswer = Symbol('secret');
var theQuestion = Symbol('secret');
interface DeepThought {
   [theQuestion]: string;
   [theAnswer]: number;
}
var d: DeepThought;
d[theQuesiton] = "why";
d[theAnswer] = 42;

This would be really nice, but so far we have not come up with a sensible design for it. It ultimately seems to hinge on making the type depend on the runtime value of these symbols. We will continue to think about it, as it is clearly a useful thing to do.

With my PR, you should at least be able to use a symbol to pull a value out of an object. It will be any, but you will no longer get an error.

JsonFreeman commented 9 years ago

@wereHamster I did a little writeup #2012 that you may be interested in.

JsonFreeman commented 9 years ago

I've merged request #1978, but I will leave this bug open, as it seems to ask for more than I provided with that change. However, with my change, the original error will go away.

RyanCavanaugh commented 9 years ago

@wereHamster can you post an update of what more you'd like to see happen here? Wasn't immediately clear to me what we have implemented vs what you posted

kitsonk commented 8 years ago

Any idea when symbol will be valid type as an indexer? Is this something that could be done as a community PR?

mhegazy commented 8 years ago

We would take a PR for this. @JsonFreeman can provide details on some of the issues that you might run into.

JsonFreeman commented 8 years ago

I actually think adding a symbol indexer would be pretty straightforward. It would work just like number and string, except that it wouldn't be compatible with either of them in assignability, type argument inference, etc. The main challenge is just making sure you remember to add logic in all the appropriate places.

wereHamster commented 8 years ago

@RyanCavanaugh, it would be nice to eventually have the last example in https://github.com/Microsoft/TypeScript/issues/1863#issuecomment-73668456 typecheck. But if you prefer you can split this issue up into multiple smaller issues which build on top of each other.

wereHamster commented 8 years ago

Was there any update on this front? AFAIU the latest version of the compiler supports only the first level described in https://github.com/Microsoft/TypeScript/issues/1863#issuecomment-73668456.

mhegazy commented 8 years ago

We would be happy to accept PRs for this change.

JsonFreeman commented 8 years ago

It might be worth tracking the two levels as two separate issues. The indexers seem fairly straightforward, but the utility is not clear. The full support with constant tracking seems quite difficult, but probably more useful.

mhegazy commented 8 years ago

Constant tracking is already tracked in https://github.com/Microsoft/TypeScript/issues/5579. this issue is for adding support for a symbol indexer, similar to string and numberic indexers.

JsonFreeman commented 8 years ago

Got it, makes sense.

DanielRosenwasser commented 7 years ago

@JsonFreeman @mhegazy an issue is available at #12932

axefrog commented 7 years ago

Just thought I'd throw my use case into the ring. I'm writing a tool that allows queries to be described by specifying plain text keys for matching against arbitrary object properties, and symbols for specifying matching operators. By using symbols for well-known operators, I avoid the ambiguity of matching an operator versus a field whose key is the same as that of the well-known operator.

Because symbols can't be specified as index keys, in contrast to what JavaScript explicitly allows, I'm forced to cast to <any> in a number of places, which degrades the code quality.

interface Query {
  [key: string|symbol]: any;
}

const Q = {
  startsWith: Symbol('startsWith'),
  gte: Symbol('gte'),
  lte: Symbol('lte')
}

const sample: Query = {
  name: {
    [Q.startsWith]: 'M',
    length: {
      [Q.lte]: 25
    }
  },
  age: {
    [Q.gte]: 18
  }
};

Use of "unlikely" first characters such as a $ character is not a suitable compromise, given the variety of data that the query engine may need to inspect.

timjacobi commented 7 years ago

Hi guys. Is there any movement in this? I need it so I would be happy to contribute the necessary changes. Haven't contributed to TS before though.

axefrog commented 7 years ago

@mhegazy @RyanCavanaugh I know you guys are incredibly busy, but could you weigh in when you get a chance? Symbols are a really important tool for architecting libraries and frameworks, and the lack of ability to use them in interfaces is a definite pain point.

zheeeng commented 7 years ago

I'm asking is there anything in progress? Sincerely hope this feature be supported.

aluanhaddad commented 7 years ago

https://github.com/Microsoft/TypeScript/pull/15473 looks related.

ORESoftware commented 7 years ago

Yeah still looking for this today, this is what I see in Webstorm:

screenshot 2017-10-08 21 37 17
aluanhaddad commented 7 years ago

That actually works

var test: symbol = Symbol();

const x = {
    [test]: 1
};

x[test];

console.log(x[test]);

console.log(x['test']);

but the type of x is not right, being inferred as

{
  [key: string]: number
}

Yeah still looking for this today, this is what I see in Webstorm:

Please note that JetBrains' own language service that is enabled by default in WebStorm, intelliJ IDEA, and so on.

DeTeam commented 6 years ago

This works in TS 2.7

const key = Symbol('key')
const a: { [key]?: number } = {}
a[key] = 5
Viatorus commented 6 years ago

Any update on this?

My problem:

export interface Dict<T> {
  [index: string]: T;

  [index: number]: T;
}

const keyMap: Dict<number> = {};

function set<T extends object>(index: keyof T) {
  keyMap[index] = 1; // Error Type 'keyof T' cannot be used to index type 'Dict<number>'
}

But this also does not work, because symbol cannot be an index type.

export interface Dict<T> {
  [index: string]: T;
  [index: symbol]: T; // Error: An index signature parameter type must be 'string' or 'number'
  [index: number]: T;
}

Expected behavior: symbol should be a valid index type

Actual behavior: symbol is not a valid index type

Using the workaround with casting as string | number seems for me very bad.

calebsander commented 6 years ago

How is util.promisify.custom supposed to be used in TypeScript? It seems that using constant symbols is supported now, but only if they are explicitly defined. So this is valid TypeScript (aside from f not being initialized):

const custom = Symbol()
interface PromisifyCustom<T, TResult> extends Function {
    [custom](param: T): Promise<TResult>
}
const f: PromisifyCustom<string, void>
f[custom] = str => Promise.resolve()

But if promisify.custom is used instead of custom, the attempt to reference f[promisify.custom] results in the error Element implicitly has an 'any' type because type 'PromisifyCustom<string, void>' has no index signature.:

import {promisify} from 'util'
interface PromisifyCustom<T, TResult> extends Function {
    [promisify.custom](param: T): Promise<TResult>
}
const f: PromisifyCustom<string, void>
f[promisify.custom] = str => Promise.resolve()

I would like to assign to a function's promisify.custom field, but it seems (given the behavior described above) that the only way to do this is to cast the function to an any type.

beenotung commented 6 years ago

I cannot understand why symbol is not allowed as key index, below code should works and is accepted by Typescript 2.8 but it is not allowed by Typescript 2.9

/**
 * Key can only be number, string or symbol
 * */
export class SimpleMapMap<K extends PropertyKey, V> {
  private o: { [k: string ]: V } = {};

  public has (k: K): boolean {
    return k in this.o;
  }

  public get (k: K): V {
    return this.o[k as PropertyKey];
  }

  public set (k: K, v: V) {
    this.o[k as PropertyKey] = v;
  }

  public getMap (k: K): V {
    if (k in this.o) {
      return this.o[k as PropertyKey];
    }
    const res = new SimpleMapMap<K, V>();
    this.o[k as PropertyKey] = res as any as V;
    return res as any as V;
  }

  public clear () {
    this.o = {};
  }
}
beenotung commented 6 years ago

I tried below, which is more 'correct' to me but it is not accepted by both version of Typescript compiler

/**
 * Key can only be number, string or symbol
 * */
export class SimpleMapMap<K extends PropertyKey, V> {
  private o: { [k: K ]: V } = {};

  public has (k: K): boolean {
    return k in this.o;
  }

  public get (k: K): V {
    return this.o[k];
  }

  public set (k: K, v: V) {
    this.o[k] = v;
  }

  public getMap (k: K): V {
    if (k in this.o) {
      return this.o[k];
    }
    const res = new SimpleMapMap<K, V>();
    this.o[k as PropertyKey] = res as any as V;
    return res as any as V;
  }

  public clear () {
    this.o = {};
  }
}
kitsonk commented 6 years ago

The status of this ticket indicates that what you are suggesting is the desired behaviour, but the core team is not at this point committing resources to add this feature enhancement, they are opening it up to the community to address.

lucasbasquerotto commented 6 years ago

@beenotung Although this is not an ideal solution, assuming that the class you posted is the only place where you need such a behaviour, you can do unsafe casts inside the class, but keeping the class and methods signatures the same, so that the consumers of the class won't see that:

/**
 * Key can only be number, string or symbol
 * */
export class SimpleMapMap<K extends PropertyKey, V> {
  private o: { [k: string]: V } = {};

  public has(k: K): boolean {
    return k in this.o;
  }

  public get(k: K): V {
    return this.o[k as any];
  }

  public set(k: K, v: V) {
    this.o[k as any] = v;
  }

  public getMap(k: K): V {
    if (k in this.o) {
    return this.o[k as any];
    }

    const res = new SimpleMapMap<K, V>();
    this.o[k as any] = res as any as V;
    return res as any as V;
  }

  public clear() {
    this.o = {};
  }
}

Because the signatures are the same, whenever you use this class, you will have the type validation applied correctly, and, when this issue is solved, you will just need to change this class (it will be transparent to the consumers).

An example of a consumer is like bellow (the code won't need any change when this issue is fixed):

const s1 = Symbol(1);
const s2 = Symbol(2);

let m = new SimpleMapMap<symbol, number>()
m.set(s1, 1);
m.set(s2, 2);
m.get(s1);
m.get(1); //error
jods4 commented 6 years ago

Typescript 3.0.1, got bitten by this. I want a record that accepts symbol but TS won't let me.

It's been 3.5 years since this issue was opened, can we have symbols now, please 🙏

The irony is that TS contradict itself. TS expands keyof any = number | string | symbol.

But then when you do record[symbol] TS refuses saying Type 'symbol' cannot be used as an indexer.

ORESoftware commented 5 years ago

Yeah I have been suffering with this one for awhile sadly, my latest question regarding this topic:

https://stackoverflow.com/questions/53404675/ts2538-type-unique-symbol-cannot-be-used-as-an-index-type

jhpratt commented 5 years ago

@RyanCavanaugh @DanielRosenwasser @mhegazy Any updates? This issue is nearing its fourth birthday.

If someone could point me in the right direction, I can give it a go. If there's tests to match, even better.

yortus commented 5 years ago

@jhpratt there's a PR up at #26797 (note the caveat about well-known symbols). There are recent design meeting notes about it in #28581 (but no resolution recorded there). There's a bit more feedback about why that PR is held up here. It seems to be regarded as a fringe/low impact issue, so maybe more upvotes on the PR may help raise the profile of the issue.

jhpratt commented 5 years ago

Thanks @yortus. I've just asked Ryan if the PR is still planned for 3.2, which is what is indicated by the milestone. Hopefully that's the case, and this will be resolved!

beenotung commented 5 years ago

The PR pointed by @yortus seems a huge change, Shouldn't the fix for this bug be very minor? e.g. adding an or statement in the condition check. (I have not located the place to change yet.)

davalapar commented 5 years ago

temp solution here https://github.com/Microsoft/TypeScript/issues/24587#issuecomment-412287117, kinda ugly but gets the job done

const DEFAULT_LEVEL: string = Symbol("__default__") as any;

another https://github.com/Microsoft/TypeScript/issues/24587#issuecomment-460650063, since linters h8 any

const ItemId: string = Symbol('Item.Id') as unknown as string;
type Item = Record<string, string>;
const shoes: Item = {
  name: 'whatever',
}
shoes[ItemId] = 'randomlygeneratedstring'; // no error
{ name: 'whatever', [Symbol(Item.Id)]: 'randomlygeneratedstring' }

i guess one of the gotchas i've noticed in using symbols though is if you have a project involving the child_process module, yes you can share types/enums/interfaces between the two process but never symbols.

it's really great to have this solved though, symbols are really great in tracking objects without polluting their keys and being required to use maps/sets, on top of that the benchmarks in the recent years show that accessing symbols are just as fast as accessing string / number keys


Edit: Turns out this approach is only works with Record<X,Y> but not Interfaces. Ended up using // @ts-ignore for now since it's still a syntactically correct and still compiles well to JS just as it should.

A thing to note though is when using // @ts-ignore on lines involving symbols, it's actually possible (and helps) to manually specify the type of that symbol. VSCode still kind of picks up on it.

const id = Symbol('ID');

interface User {
  name: string;
  age: number;
}

const alice: User = {
  name: 'alice',
  age: 25,
};

// @ts-ignore
alice[id] = 'maybeSomeUUIDv4String';

// ...

// then somewhere, when you need this User's id

// @ts-ignore
const id: string = alice[id];

console.log(id); // here you can hover on id and it will say it's a string
Neonit commented 5 years ago

Don't know, if anyone has started something to fix this, but if not, I have now.

My time is limited, though and I have zero knowledge about the Typescript sources. I've made a fork (https://github.com/Neonit/TypeScript), but no pull request, yet, because I don't want to molest the devs with unfinished (?) changes. I would ask everyone to contribute what they can to my fork. I will eventually issue a PR then.

So far I have found a way to fix the interface index type restriction. I don't know, if there's more to it. I was able to index an object with a symbol in TS 3.4 without any fixes. (https://www.typescriptlang.org/play/#src=const%20o%20%3D%20%7B%7D%3B%0D%0Aconst%20s%20%3D%20Symbol('s')%3B%0D%0A%0D%0Ao%5Bs%5D%20%3D%20123%3B)

Have a look at my commit to see what I found: https://github.com/Neonit/TypeScript-SymbolKeys/commit/11cb7c13c2494ff32cdec2d4f82673058c825dc3

Missing:

I hope this will get things started finally after years of waiting.

nisimjoseph commented 5 years ago

the fix looks good. can TypeScript dev take a look on it?

zhongyuanjia commented 5 years ago

hello, any progress for this?

simonwep commented 4 years ago

Just opened a SO thread about this: https://stackoverflow.com/questions/59118271/using-symbol-as-object-key-type-in-typescript

Why isn't that possible? Isn't symbol another primitive type like number - so why's there a difference?

kaelzhang commented 4 years ago

Hello, any progress for this?

FIVE years have been passed!

RyanCavanaugh commented 4 years ago

You're not gonna believe how long it took C++ to get closures 😲

ljharb commented 4 years ago

lol fair, but C++ isn't marketing itself as a superset of a language that has closures :-p

RyanCavanaugh commented 4 years ago

@ljharb keep beating that horse, it's still twitching 😛

DanielRosenwasser commented 4 years ago

For those who are targeting newer runtimes, why not use a Map? I've anecdotally found that a lot of developers don't know Maps exist, so I'm curious whether there's another scenario that I'm missing.

let m = new Map<symbol, number>();
let s = Symbol("arbitrary symbol!");

m.set(s, 1000);
let a = m.get(s);
slikts commented 4 years ago

Maps and objects have different use cases.

ljharb commented 4 years ago

@DanielRosenwasser well-known symbols are used as protocols; a Map key of Symbol.match, for example, won't make the object RegExp-like (and any object may want a Symbol.iterable key to make it iterable without having to explicitly use TS built-in Iterable types).