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
artalar commented 4 years ago

Almost 5 years(

Please, implement this feature, I can't writing code normally..

DanielRosenwasser commented 4 years ago

Can participants provide actual examples in their use-cases?

I don't understand the protocol example and why it's not possible today.

Here's an example of StringConvertible

const intoString = Symbol("intoString")

/**
 * Something that can be converted into a string.
 */
interface StringConvertible {
    [intoString](): string;
}

/**
 * Something that is adorable.
 */
class Dog implements StringConvertible {
    [intoString](): string {
        return "RUFF RUFF";
    }
}

/**
 * @see {https://twitter.com/drosenwasser/status/1102337805336768513}
 */
class FontDog implements StringConvertible {
    [intoString](): string {
        return "WOFF WOFF";
    }
}

console.log(new Dog()[intoString]())
console.log(new FontDog()[intoString]())

Here's an example of Mappable or Functor (lack of higher-order type constructors aside):

const map = Symbol("map")

interface Mappable<T> {
    [map]<U>(f: (x: T) => U): Mappable<U>
}

class MyCoolArray<T> extends Array<T> implements Mappable<T> {
    [map]<U>(f: (x: T) => U) {
        return this.map(f) as MyCoolArray<U>;
    }
}
ljharb commented 4 years ago

@DanielRosenwasser it seems like you're assuming all objects have an interface or are a class instance or are known in advance; using your last example, I should be able to install map, say, onto any javascript object (or at least, an object whose type allows for any symbol to be added to it), which then makes it Mappable.

DanielRosenwasser commented 4 years ago

Installing a property (symbol or not) onto an object after the fact is part of a different feature request (often called "expando properties" or "expando types").

Lacking that, the type you'd need for a symbol index signature would provide very little as a TypeScript user, right? If I understand correctly, the type would need to be something like unknown or just any to be somewhat useful.

interface SymbolIndexable {
   [prop: symbol]: any; // ?
}
ljharb commented 4 years ago

In the case of protocols, it's generally a function, but sure, it could be unknown.

What I need is the symbol (and bigint) equivalent of type O = { [k: string]: unknown }, so I can represent an actual JS object (something that can have any kind of key) with the type system. I can narrow that later as needed, but the base type for a JS object would be { [k: string | bigint | symbol | number]: unknown }, essentially.

brainkim commented 4 years ago

Ah I think I see @DanielRosenwasser point. I currently have code with an interface like:

export interface Environment<T> {
    [Default](tag: string): Intrinsic<T>;
    [Text]?(text: string): string;
    [tag: string]: Intrinsic<T>;
    // TODO: allow symbol index parameters when typescript gets its shit together
    // [tag: symbol]: Intrinsic<T>;
}

where Intrinsic<T> is a function type, and I want to allow devs to define their own symbol properties on environments similar to strings, but insofar as you could add [Symbol.iterator], [Symbol.species] or custom symbol properties to any interface, the index signature with symbols would incorrectly restrict any objects implementing these properties.

So what you’re saying is you can’t make the value type of indexing by symbol any more specific than any? Could we somehow use the unique symbol vs symbol distinction to allow this? Like could we make the index signature a default for regular symbols and allow unique/well-known symbols to override the index type? Even if it weren’t typesafe, being able get/set properties by symbol indexes arbitrarily would be helpful.

The alternative would be having users extend the Environment interface themselves with their symbol properties, but this doesn’t provide any additional type safety insofar as users can type the Symbol as whatever on the object.

artalar commented 4 years ago

@DanielRosenwasser here a real example of my production code. A State reused in many places as a map and may accept key of atom (domained feature). Currently I need to add symbol support, but I get a lot of errors:

Anyway current behavior is incompatible with ES standard that is wrong.

brainkim commented 4 years ago

One additional late-night thought I had regarding symbol types. Why isn’t this an error?

const foo = {
  [Symbol.iterator]: 1,
}

JS expects all Symbol.iterator properties to be a function which returns an iterator, and this object would break a lot of code if it was passed around in various places. If there were a way to globally define symbol properties for all objects, we could allow for specific symbol index signatures while also allowing global overrides. It would be typesafe, right?

Neonit commented 4 years ago

I am also not understanding, why a use case would be needed here. This is an ES6 incompatibility, which should not exist in a language wrapping ES6.

In the past I posted my findings on how this could be fixed here in this thread and if this code is not lacking important checks or features I doubt it's more time consuming to integrate it into the codebase than continuing this discussion.

I just didn't do a pull request, because I do not know about Typescript's test framework or requirements and because I do not know whether changes in different files would be necessary to make this work in all cases.

So before continuing to invest time to read and write here, please check if adding the feature would be less time consuming. I doubt anyone would complain about it being in Typescript.

Apart from all that the general use case is if you want to map values onto arbitrary symbols. Or for compatibility with non-typed ES6 code.

Tyler-Murphy commented 4 years ago

Here's an example of a place I think this would be helpful: https://github.com/choojs/nanobus/pull/40/files. In practice, eventNames can be symbols or strings, so I'd like to be able to say

type EventsConfiguration = { [eventName: string | Symbol]: (...args: any[]) => void }

on the first line.

But I might be misunderstanding something about how I should be doing this.

evg656e commented 4 years ago

Simple use-case can't be done without pain:

type Dict<T> = {
    [key in PropertyKey]: T;
};

function dict<T>() {
    return Object.create(null) as Dict<T>;
}

const has: <T>(dict: Dict<T>, key: PropertyKey) => boolean = Function.prototype.call.bind(Object.prototype.hasOwnProperty);

function forEach<T>(dict: Dict<T>, callbackfn: (value: T, key: string | symbol, dict: Dict<T>) => void, thisArg?: any) {
    for (const key in dict)
        if (has(dict, key))
            callbackfn.call(thisArg, dict[key], key, dict);
    const symbols = Object.getOwnPropertySymbols(dict);
    for (let i = 0; i < symbols.length; i++) {
        const sym = symbols[i];
        callbackfn.call(thisArg, dict[sym], sym, dict); // err
    }
}

const d = dict<boolean>();
const sym = Symbol('sym');
const bi = 9007199254740991n;

d[1] = true;
d['x'] = true;
d[sym] = false; // definitely PITA
d[bi] = false; // another PITA

forEach(d, (value, key) => console.log(key, value));
DanielRosenwasser commented 4 years ago

I am also not understanding, why a use case would be needed here.

@neonit there are PRs to address this, but my understanding is that there are subtle issues with how the feature interacts with the rest of the type system. Lacking solutions to that, the reason I ask for use-cases is because we can't just work/focus on every feature at once that we'd like to - so use-cases need to justify work being done which includes long-term maintenance of a feature.

It seems that in fact, most people's imagined use cases won't be solved as easily as they imagine (see @brainkim's response here https://github.com/microsoft/TypeScript/issues/1863#issuecomment-574550587), or that they're solved equally well via symbol properties (https://github.com/microsoft/TypeScript/issues/1863#issuecomment-574538121) or Maps (https://github.com/microsoft/TypeScript/issues/1863#issuecomment-572733050).

I think @Tyler-Murphy gave the best example here in that you can't write constraints, which can be very useful for something like a type-safe event emitter that supports symbols.

So before continuing to invest time to read and write here, please check if adding the feature would be less time consuming. I doubt anyone would complain about it being in Typescript.

This is always easier to say when you don't have to maintain the project! 😄 I understand that this is something useful for you, but I hope you respect that.

This is an ES6 incompatibility

There are plenty of constructs that TypeScript can't type easily because it would be infeasible. I'm not saying this is impossible, but I don't believe that this an appropriate way to frame this issue.

brainkim commented 4 years ago

So it seems like the inability to add symbol keys as index signatures comes from the fact that there are global well-known Symbols which require their own typings, which symbol index types would inevitably clash with. As a solution, what if we had a global module/interface which represented all known symbols?

const Answerable = Symbol.for("Answerable");
declare global {
  interface KnownSymbols {
    [Answerable](): string  | number;
  }
}

interface MyObject {
  [name: symbol]: boolean;
}

const MySymbol = Symbol.for("MySymbol");
const obj: MyObject = {
  [MySymbol]: true,
};

obj[Answerable] = () => "42";

By declaring additional properties on the global KnownSymbols interface, you allow all objects to be indexed by that symbol and restrict the value of the property to undefined/your value type. This would immediately provide value by allowing typescript to provide typings for the well-known symbols provided by ES6. Adding a Symbol.iterator property to an object which is not a function which returns an iterator should clearly be an error, but it is not one currently in typescript. And it would make adding well-known symbol properties to already existing objects much easier.

This usage of a global module would also allow Symbols to be used as arbitrary keys as well, and therefore in index signatures. You would just give the global known symbol properties precedence over the local index signature type.

Would implementing this proposal allow index signature types to move forward?

sophistifunk commented 4 years ago

Individual use-cases are irrelevant. If it's cromulent JavaScript, it needs to be expressible in TS definitions.

weswigham commented 4 years ago

but my understanding is that there are subtle issues with how the feature interacts with the rest of the type system

More like "refactors how index signatures internally work entirely so is a scary big change and raises cromulet questions as to how index signatures are or should differ from mapped types that don't use the template variable" to be precise.

It mostly led to a discussion on how we fail to recognize closed types vs open types. In this context, a "closed" type would be a type with a finite set of keys whose values cannot be extended. The keys of a kind of exact type, if you will. Meanwhile an "open" type in this context is a type which, when subtyped, is open to having more keys added (which, under our current subtyping rules, sorta all types are mostly sometimes, except types with index signatures which very explicitly are). Index signatures imply making an open type, while mapped types are largely related as though they are operating over closed types. This usually works well enough because most code, in practice, is written with a structure compatible with closed object types. This is why flow (which has explicit syntax for closed vs open object types) defaults to closed object types. This comes to a head with generic index keys; If I have a T extends string, as T is instantiated broader and broader types (from "a" to "a" | "b" to string), the object produced is more and more specialized, right up until we swap over from "a" | "b" | ... (every other possible string) to string itself. Once that happens, suddenly the type is very open, and while every property may potentially exist to access, it becomes legal to, eg, assign an empty object to it. That's what happens structurally, but when we relate the generics in mapped types, we ignore that - a string constraint on a generic mapped type key is essentially related as though it makes all possible keys exist. This logically follows from a simple variance-based view of the key type, but is only correct is the keys come from a closed type (which, ofc, a type with an index signature is never actually closed!). So, if we want to be backwards compatible, we can't treat {[x: T]: U} the same as {[_ in T]: U}, unless, ofc, we want to, since in the non-generic case {[_ in T]: U} becomes {[x: T]: U}, adjust how we handle the variance of mapped type keys to properly account for the open type "edge", which is an interesting change in its own right that could have ecosystem ramifications.

Pretty much: Because it brings mapped types and index signatures much closer together, it raised a bunch of questions on how we handle both of them that we don't have satisfactory or conclusive answers to yet.

RyanCavanaugh commented 4 years ago

Individual use-cases are irrelevant.

This is, politely, pure madness. How in tarnation do we know whether or not we're adding a feature with the behavior people want without use cases by which to judge that behavior?

We're not trying to be difficult here by asking these questions; we are literally trying to ensure that we implement the things people are asking for. It would be a true shame if we implemented something we thought was "indexing with symbols", only to have the very same people in this thread come back and say that we did it totally wrong because it didn't address their particular use cases.

You're asking us to fly blind. Please don't! Please tell us where you would like the plane to go!

sophistifunk commented 4 years ago

My bad, I could've been clearer about what I meant; it seemed to me that people felt they had to justify their actual code use-cases, rather than their desire to describe it more accurately via TS

Neonit commented 4 years ago

So if I understand it correctly, this is mainly about the following problem:

const sym = Symbol();
interface Foo
{
    [sym]: number;
    [s: symbol]: string; // just imagine this would be allowed
}

Now the Typescript compiler would see this as a conflict, because Foo[sym] has an ambivalent type. We have the same issue with strings already.

interface Foo
{
    ['str']: number; // <-- compiler error: not assignable to string index type 'string'
    [s: string]: string;
}

The way this is handled with string indices is that specific string indices are just not allowed, if there is a general specification for string keys and their type is incompatible.

I guess for symbols this would be an omnipresent issue, because ECMA2015 defines standard symbols like Symbol.iterator, which can be used on any object and thus should have a default typing. Which they oddly do not have apparently. At least the playground does not allow me to run the Symbol.iterator example from MDN.

Assuming it is planned to add predefined symbol typings it would always lead to a general [s: symbol]: SomeType definition to be invalid, because the predefined symbol indices already have incompatible types so there cannot exist a common general type or maybe it would need to be a function type, because most (/all?) predefined symbol keys are of function type.

A problem with the mix of general and specific index types is the type determination when the object is indexed with a value not known at compile time. Imagine my above example with the string indices would be valid, then the following would be possible:

const foo: Foo = {str: 42, a: 'one', b: 'two'};
const input: string = getUserInput();
const value = foo[input];

The same problem would apply to symbol keys. It is impossible to determine the exact type of value at compile-time. If the user inputs 'str', it would be number, otherwise it would be string (at least Typescript would expect it to be a string, whereas it likely may become undefined). Is this the reason we do not have this feature? One could workaround this by giving value a union type containing all possible types from the definition (in this case number | string).

DanielRosenwasser commented 4 years ago

@Neonit Well, that's not the issue that's halted progress on an implementation, but that's exactly one of the issues that I waws trying to point out - that depending on what you're trying to do, symbol indexers might not be the answer.

If this feature was implemented, ECMAScript's built-in symbols wouldn't necessarily ruin everything because not every type uses those symbols; but any type that does define a property with a well-known symbol (or any symbol that you yourself define) would likely be limited to a less-useful index signature for symbols.

That's really the the thing to keep in mind - the "I want to use this as a map" and the "I want to use symbols to implement protocols" use-cases are incompatible from a type-system perspective. So if you had anything like that in mind, then symbol index signatures might not help you, and you might be better-served via a explicit symbol properties or maps.

riggs commented 4 years ago

What about something like a UserSymbol type that's just symbol minus built-in symbols? The very nature of symbols ensures there won't ever be accidental collisions.

Edit: Thinking about this more, well-known symbols are just sentinels that happen to be implemented using Symbol. Unless the goal is object serialization or introspection, code probably should treat these sentinels different from other symbols, because they have special meaning to the language. Removing them from the symbol type will likely make (most) code using 'generic' symbols more safe.

leidegre commented 4 years ago

@RyanCavanaugh here's my flight plan.

I have a system in which I use symbols like this for properties.

const X = Symbol.for(":ns/name")

const txMap = {
  [X]: "fly away with me!"
}

transact(txMap) // what's the index signature here?

In this case I want the txMap to fit the type signature of transact. But to my knowledge I cannot express this today. In my case, transact is part of a library that doesn't know what to expect. I do something like this for properties.

// please forgive my tardiness but in essence this is how I'm typing "TxMap" for objects
type TxMapNs = { [ns: string]: TxMapLocal }
type TxMapLocal = { [name: string]: string | TxMapNs } // leaf or non leaf

I can generate the set of types that fit transact from schema and use that. For that I'd do something like this and rely on declaration merging.

interface TxMap = {
  [DB_IDENT]: symbol // leaf
  [DB_VALUE_TYPE]?: TxMap // not leaf
  [DB_CARDINALITY]?: TxMap
}

But it would be nice if I could at least fallback to an index signature for symbols, I only expect transact to be handed plain JavaScript objects, I also only use symbols from the global symbol registry in this case. I do not use private symbols.


I should add that this is a bit of a pain.

const x = Symbol.for(":x");
const y = Symbol.for(":x");

type X = { [x]: string };
type Y = { [y]: string };

const a: X = { [x]: "foo" };
const b: Y = { [x]: "foo" }; // not legal
const c: X = { [y]: "foo" }; // not legal
const d: Y = { [y]: "foo" };

It would be super awesome if TypeScript could understand that symbols created via the Symbol.for function actually are the same.


This is also super annoying.

function keyword(ns: string, name: string): unique symbol { // not possible, why?
  return Symbol.for(":" + ns + "/" + name)
}

const x: unique symbol = keyword("db", "id") // not possible, why?

type X = {
  [x]: string // not possible, why?
}

That little utility function let's me enforce a convention over my global symbol table. however, I cannot return a unique symbol, even if it is created via the Symbol.for function. Because of the way TypeScript it doing things, it is forcing me to forgo certain solutions. They just don't work. And I think that's sad.

aaronpowell commented 4 years ago

I've come across another use case where symbol as a indexing value would be useful, when working with ES Proxies to create a factory function that wraps an object with a proxy.

Take this example:

let original = {
    foo: 'a',
    bar: 'b',
    baz: 1
};

function makeProxy<T extends Object>(source: T) {
    return new Proxy(source, {
        get: function (target, prop, receiver) {
            return target[prop];
        }
    });
}

let proxied = makeProxy(original);

To match up the ProxyConstructor type signature the generic argument must extend Object, but that then errors because the generic argument isn't keyed. So we can extend the type signature:

function makeProxy<T extends Object & { [key: string]: any}>(source: T) {

But now it'll raise an error because the 2nd argument (prop) of get on ProxyHandler is of type PropertyKey which so happens to be PropertyKey.

So I'm unsure how to do this with TypeScript due to the restrictions of this issue.

beenotung commented 4 years ago

@aaronpowell What is the problem you're facing? I see it is behaving fine:

let original = {
    foo: 'a',
    bar: 'b',
    baz: 1
};

function makeProxy<T extends Object>(source: T) {
    return new Proxy(source, {
        get: function (target, prop, receiver) {
            return target[prop];
        }
    });
}

let proxied = makeProxy(original);

function assertString(s:string){}
function assertNumber(x:number){}

assertString(proxied.foo); // no problem as string
assertNumber(proxied.baz); // no problem as number
console.log(proxied.foobar); // fails as expected: error TS2339: Property 'foobar' does not exist on type '{ foo: string; bar: string; baz: number; }'.

tsconfig.json:

{
  "compilerOptions": {
    "module": "esnext",
    "moduleResolution": "node",
    "target": "es2015"
  }

package.json:

{
  "devDependencies": {
    "typescript": "~3.4.5"
  }
}
aaronpowell commented 4 years ago

@beenotung I see an error in the playground:

image

beenotung commented 4 years ago

@aaronpowell the error appear when you enable 'strict' flag in the 'compilerOptions' in tsconfig.json.

So under the current version of typescript compiler, you've to either turn off the strict mode or cast the target into any ...

aaronpowell commented 4 years ago

Sure, but an any cast isn't really ideal and disabling strict mode is just loosening restrictions in the type safety.

Oloompa commented 4 years ago

Reading messages i imagine the next "solution" will probably be to "disable typescript".

We shouldn't have to search for stopgap solutions neither have to explain why we need it.

It's a standard feature of javascript so we need it in typescript.

davidgilbertson commented 4 years ago

@DanielRosenwasser my use case is similar to that of @aaronpowell - a seeming mismatch in the ProxyHandler interface and TypeScript's rules preventing me from properly typing proxy handler traps.

A boiled down example demonstrating the issue:

const getValue = (target: object, prop: PropertyKey) => target[prop]; // Error

As far as I can tell, it's impossible to craft any type for target that will avert the error yet allow only objects that can legitimately be accessed by PropertyKey.

I'm a TypeScript newbie so please forgive me if I'm missing something obvious.

marijnh commented 4 years ago

Another use case: I'm trying to have a type {[tag: symbol]: SomeSpecificType} for callers to provide a map of tagged values of a specific type in a way that benefits from the compactness of object literal syntax (while still avoiding the name clash risks of using plain strings as tags).

dead-claudia commented 4 years ago

Another use case: I'm trying to iterate all enumerable properties of an object, symbols and strings both. My current code looks something like this (names obscured):

type ContextKeyMap = Record<PropertyKey, ContextKeyValue>

function setFromObject(context: Context, object: ContextKeyMap) {
    for (const key in object) {
        if (hasOwn.call(object, key)) context.setKey(key, object[key])
    }

    for (const symbol of Object.getOwnPropertySymbols(object)) {
        if (propertyIsEnumerable.call(object, symbol)) {
            context.setKey(symbol, object[symbol as unknown as string])
        }
    }
}

I'd very strongly prefer to be able to just do this:

type ContextKeyMap = Record<PropertyKey, ContextKeyValue>

function setFromObject(context: Context, object: ContextKeyMap) {
    for (const key in object) {
        if (hasOwn.call(object, key)) context.setKey(key, object[key])
    }

    for (const symbol of Object.getOwnPropertySymbols(object)) {
        if (propertyIsEnumerable.call(object, symbol)) {
            context.setKey(symbol, object[symbol])
        }
    }
}
ahnpnl commented 4 years ago

I have also issue with indexing with symbols. My code is as follow:

const cacheProp = Symbol.for('[memoize]')

function ensureCache<T extends any>(target: T, reset = false): { [key in keyof T]?: Map<any, any> } {
  if (reset || !target[cacheProp]) {
    Object.defineProperty(target, cacheProp, {
      value: Object.create(null),
      configurable: true,
    })
  }
  return target[cacheProp]
}

I followed the solution by @aaronpowell and somehow managed to workaround it

const cacheProp = Symbol.for('[memoize]') as any

function ensureCache<T extends Object & { [key: string]: any}>(target: T, reset = false): { [key in keyof T]?: Map<any, any> } {
  if (reset || !target[cacheProp]) {
    Object.defineProperty(target, cacheProp, {
      value: Object.create(null),
      configurable: true,
    })
  }

  return target[cacheProp]
}

Casting to any from symbol isn't so nice indeed.

Really appreciated for any other solutions.

dead-claudia commented 4 years ago

@ahnpnl For that use case, you'd be better off using a WeakMap than symbols, and engines would optimize that better - it doesn't modify target's type map. You may still have to cast it, but your cast would live in the return value.

beenotung commented 4 years ago

A workaround is to use generic function to assign value ...

var theAnswer: symbol = Symbol('secret');
var obj = {} as Record<symbol, number>;
obj[theAnswer] = 42; // Currently error, but should be allowed

Object.assign(obj, {theAnswer: 42}) // allowed
mellonis commented 4 years ago

A workaround is to use generic function to assign value ...

var theAnswer: symbol = Symbol('secret');
var obj = {} as Record<symbol, number>;
obj[theAnswer] = 42; // Currently error, but should be allowed

Object.assign(obj, {theAnswer: 42}) // allowed

I'm not agree. These three lines are equal to each other:

Object.assign(obj, {theAnswer: 42});
Object.assign(obj, {'theAnswer': 42});
obj['theAnswer'] = 42;
SanderElias commented 4 years ago

@DanielRosenwasser
I have this use case, In the playground link, I also solved it by using maps, but take a look, its ugly.

const system = Symbol('system');
const SomeSytePlugin = Symbol('SomeSytePlugin')

/** I would prefer to have this working in TS */
interface Plugs {
    [key: symbol]: (...args: any) => unknown;
}
const plugins = {
    "user": {} as Plugs,
    [system]: {} as Plugs
}
plugins[system][SomeSytePlugin] = () => console.log('awsome')
plugins[system][SomeSytePlugin](); ....

Playground Link

Using symbols here rules out the possible accidental overwrite that happens when using strings. It makes the whole system more robust and easier to maintain.

If you have an alternative solution that works with TS and has the same readability in the code, I'm all ears.

H2rmone commented 4 years ago

Any official explain for this issue?

MingweiSamuel commented 4 years ago

A workaround is to use generic function to assign value ...

var theAnswer: symbol = Symbol('secret');
var obj = {} as Record<symbol, number>;
obj[theAnswer] = 42; // Currently error, but should be allowed

Object.assign(obj, {theAnswer: 42}) // allowed

You're looking for

Objet.assign(obj, { [theAnswer]: 42 });

However there isn't a way to read x[theAnswer] back out without a cast AFAIK see comment two below

leidegre commented 4 years ago

For the love of God, please make this a priority.

beenotung commented 4 years ago

You're looking for

Objet.assign(obj, { [theAnswer]: 42 });

However there isn't a way to read x[theAnswer] back out without a cast AFAIK

As pointed out by mellonis and MingweiSamuel, the workarounds using generic function are:

var theAnswer: symbol = Symbol("secret");
var obj = {} as Record<symbol, number>;

obj[theAnswer] = 42; // Not allowed, but should be allowed

Object.assign(obj, { [theAnswer]: 42 }); // allowed

function get<T, K extends keyof T>(object: T, key: K): T[K] {
  return object[key];
}

var value = obj[theAnswer]; // Not allowed, but should be allowed

var value = get(obj, theAnswer); // allowed
james4388 commented 4 years ago

Five years and Symbol as index still not allowed

james4388 commented 4 years ago

Found a work-around on this case, it not generic but work in some case:

const SYMKEY = Symbol.for('my-key');

interface MyObject {   // Original object interface
  key: string
}

interface MyObjectExtended extends MyObject {
  [SYMKEY]?: string
}

const myObj: MyObject = {
  'key': 'value'
}

// myObj[SYMKEY] = '???' // Not allowed

function getValue(obj: MyObjectExtended, key: keyof MyObjectExtended): any {
  return obj[key];
}

function setValue(obj: MyObjectExtended, key: keyof MyObjectExtended, value: any): void {
  obj[key] = value
}

setValue(myObj, SYMKEY, 'Hello world');
console.log(getValue(myObj, SYMKEY));
devinrhode2 commented 3 years ago

@james4388 How is your example any different from the one from @beenotung?

dead-claudia commented 3 years ago

FYI: https://github.com/microsoft/TypeScript/pull/26797

(Just found it - I'm not actually part of the TS team.)

monfera commented 3 years ago

I agree that if this is safe:

const keepAs = <
  R extends Record<string, unknown>,
  From extends keyof R,
  M extends Record<string, From>,
  To extends keyof M,
  S extends { [P in To]: R[M[P]] }
>(
  a: R[],
  m: M,
): S[] => a.map((r) => Object.assign({}, ...Object.entries(m).map(([k, v]) => ({ [k]: r[v] }))));

then the analogous

const keepAs2 = <
  R extends Record<PropertyKey, unknown>,
  From extends keyof R,
  M extends Record<PropertyKey, From>,
  To extends keyof M,
  S extends { [P in To]: R[M[P]] }
>(
  a: R[],
  m: M,
): S[] => a.map((r) => Object.assign({}, ...Reflect.ownKeys(m).map((k) => ({ [k]: r[m[k]] }))));

is also safe

Gnucki commented 3 years ago

My poor workaround for some cases :

const bar: Record<any, string> = {};
const FOO = Symbol('foo');

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const aFOO = FOO as any;

bar[aFOO] = 'sad';
ExE-Boss commented 3 years ago

@Gnucki It’s probably better to do, since the as any type assertion gets removed during compilation:

const bar: Record<any, string> = {};
const FOO = Symbol('foo');

bar[
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    FOO as any
] = 'sad';

which compiles to:

const bar = {};
const FOO = Symbol('foo');

bar[FOO] = 'sad';

Whereas your code compiles to:

const bar = {};
const FOO = Symbol('foo');

const aFoo = FOO as any;

bar[aFOO] = 'sad';

which causes the local DeclarativeEnvironmentRecord to have two const bindings pointing to the same Symbol value.

Repugraf commented 3 years ago

Is there any explanation of this from typescript maintainers team? I can't see any reason why this is not handled. This is very useful when creating 3 party libraries. Without this fix we'll have to use @ts-ignore comments all over the place

riggs commented 3 years ago

I'll reiterate my suggestion to make a TS language-level distinction between user-instantiated symbols and 'well-known' JS symbols.

Quoting myself from earlier:

Well-known symbols are just sentinels that happen to be implemented using Symbol. Unless the goal is object serialization or introspection, code probably should treat these sentinels different from other symbols, because they have special meaning to the language. Removing them from the symbol type will likely make (most) code using 'generic' symbols more safe.

ljharb commented 3 years ago

@riggs that would not fit a number of use cases; it's absolutely critical that everything for which typeof x === 'symbol' is in the symbol type, otherwise APIs can't be typed properly.

riggs commented 3 years ago

Given that TypeScript is a superset of JS created for the explicit purpose of type safety, create two new types, LanguageSymbol and Sentinel:

Symbol remains untouched, but maybe gets a flag warning about direct usage in a future version.

Sentinels are, by definition, freed from the concerns relating to well-known symbols. Preserving this behavior at runtime does incur a small overhead, but would likely only occur when doing introspection, at which point extra care is likely warranted. I suspect most code doing runtime type checks for symbols are expecting to not be dealing with a LanguageSymbol in the first place.