Typescript: Consider adding `symbolof` type operator, like `keyof` but for unique symbol properties

Created on 15 Dec 2017  Â·  19Comments  Â·  Source: microsoft/TypeScript

EDIT: The example code below mostly does work as per https://github.com/Microsoft/TypeScript/issues/20721#issuecomment-356476329. The only part remaining unsolved is that there is no equivelent of keyof for unique symbols. The suggestion is to add a symbolof type operator.

TypeScript Version: 2.7.0-dev.20171215

Thanks to #15473, unique symbols and string consts can be used as keys in types. But they don't work with _indexed access types_ or _index type queries_ (#11929), as demonstated below.

Code

const SYM = Symbol('A Symbol');
const STR = 'A String';

interface Foo {
    'lit': string;
    [STR]: boolean;
    [SYM]: number;
}

declare let foo: Foo;
let v1 = foo['lit'];    // v1 is string
let v2 = foo[STR];      // v2 is boolean
let v3 = foo[SYM];      // v3 is number

// indexed access types
type T1 = Foo['lit'];   // T1 = string
type T2 = Foo[STR];     // ERROR: Cannot find name 'STR'
type T3 = Foo[SYM];     // ERROR: Cannot find name 'SYM'

// index type query
type K = keyof Foo;     // K = 'A string' | 'lit'      (but no SYM)

Expected behavior:
No errors. T2 is boolean and T3 is number. keyof Foo is 'A string' | 'lit' | SYM

Actual behavior:
Errors for T2 and T3, and keyof Foo doesn't include SYM.

I know there are several overlapping features in play here, but would like to know if all of the actual behaviour above is by design. E.g., maybe not having symbols show up in keyof queries is by design, but what about the fact that we can't query types with Foo[STR] or Foo[SYM], even though the compiler knows the types and the syntax is consistent with Foo['lit']?

Fixed Suggestion

Most helpful comment

With #23592 keyof supports numeric literals and unique symbols.

All 19 comments

I hope keyof will support symbols. Symbol keys are not enumerable but they are not hidden; you should be able to test a symbol is a key of an object.

@yortus Indexes work, you're just accessing the type of a const incorrectly:

const SYM = Symbol('A Symbol');
const STR = 'A String';

interface Foo {
    'lit': string;
    [STR]: boolean;
    [SYM]: number;
}

declare let foo: Foo;
let v1 = foo['lit'];    // v1 is string
let v2 = foo[STR];      // v2 is boolean
let v3 = foo[SYM];      // v3 is number

// indexed access types
type T1 = Foo['lit'];   // T1 = string
type T2 = Foo[typeof STR];     // T2 = boolean (note `typeof`)
type T3 = Foo[typeof SYM];     // T3 = number (note `typeof`)

As for making keyof return symbols.... Object.keys only returns string keys at runtime, and many other JS constructs only operate over string keys. I think we may do symbolof operator to maintain compatibility and keep a distinction between the two namespaces.

Thanks @weswigham. So unique symbol values (and const strings) do not create same-named unit types. And this is by design as explained by @mhegazy in https://github.com/Microsoft/TypeScript/issues/20898#issuecomment-356404475.

I think symbolof would be a good addition if it means unique symbol keys can play a part in mapped types and other type inference scenarios.

I updated the title and description to more clearly reflect what is being suggested here.

Here's an example of where a symbolof operator could be used.

const sym = Symbol();
const obj = { num: 0, str: 's', [sym]: sym };

function set <T extends object, K extends keyof T> (obj: T, key: K, value: T[K]): T[K] {
  return obj[key] = value;
}

const val = set(obj, 'str', '');
// string
const valB = set(obj, 'num', '');
// Expect type error
// Argument of type '""' is not assignable to parameter of type 'number'.
const valC = set(obj, sym, sym);
// Unexpected type error
// Argument of type 'unique symbol' is not assignable to parameter of type '"str" | "num"'.

If we had a symbolof we could do:

function set <T extends object, K extends keyof T | symbolof T> (obj: T, key: K, value: T[K]): T[K] {
  return obj[key] = value;
}

const val = set(obj, 'str', '');
// string
const valB = set(obj, 'num', '');
// Expect type error
// Argument of type '""' is not assignable to parameter of type 'number'.
const valC = set(obj, sym, sym);
// symbol

Using overloads we can work around the issue somewhat, but we still have no type checking of symbol properties.

function set (obj: object, key: symbol, value: any): any
function set <T extends object, K extends keyof T> (obj: T, key: K, value: T[K]): T[K];
function set (obj, key, value) {
  return obj[key] = value;
}

const val = set(obj, 'str', '');
// string
const valB = set(obj, 'num', '');
// Expect type error
// Argument of type '""' is not assignable to parameter of type 'number'.
const valC = set(obj, sym, sym);
// any

I've added significant justification for keyof supporting both symbol and string properties in the "other issue" (I didn't see the significance when I checked for duplicates).

Those watching this issue may be interested in that, apologies for the dupe.

https://github.com/Microsoft/TypeScript/issues/21983

Sorry, me again. More specifically the justification (which I think is fairly concrete) starts at this comment: https://github.com/Microsoft/TypeScript/issues/21983#issuecomment-366237751

I'm very in favor of at least a symbolof operator, although personally I'm even more in favor of just having keyof return symbols as well. Having a separate operator in cases when you want to operate on both would be pretty cumbersome and I imagine for that reason a lot of types would continue to be written only with keyof even if at runtime they'd have no problem handling symbols as well.

In the hypothetical case where you really need specifically string keys you'd presumably get a compile error somewhere from typescript when you try to pass or assign a string | symbol to a spot that requires a string. And then you could rewrite keyof T to be string & keyof T to fix the error.

Is it actually very common to require specifically strings though when you have a keyof T type? The only specific case I saw mentioned was the return type of Object.keys, but that doesn't actually return a keyof T in typescript. It just returns string[] because typescript can't be sure that there won't be extra fields on T at runtime that it doesn't know about.

Edit: Also notable is the fact that keyof T initially had a type of string | number when it was first added but was changed to be only string shortly afterwards in #12425 with the reason:

This more accurately reflects how properties work in JavaScript and allows us to use keyof T as the inferred type of a for...in variable when the object is of a type parameter type.

To me that pretty clearly shows that accurately reflecting how properties work in JavaScript is a goal of keyof, and part of that is that symbols can be properties. As MDN puts it:

A symbol value may be used as an identifier for object properties; this is the data type's only purpose.

It seems to me to be a fairly big hole in typescript to go through the trouble of adding the symbol type but then not include them in the keyof operator, when being a key of an object is their only purpose.

Admittedly the part about for...in being part of the motivation for removing number from keyof does not help the argument for symbols to be included since they are never included in for...in either, but it seems like it wouldn't be that hard to handle that specific case specially.

This was my view too but it’s a breaking change for TS 2.x unfortunately.

See here for the discussion:
https://github.com/Microsoft/TypeScript/issues/21983

On Fri, 30 Mar 2018 at 01:08, Kevin Donnelly notifications@github.com
wrote:

I'm very in favor of at least a symbolof operator, although personally
I'm even more in favor of just having keyof return symbols as well.
Having a separate operator in cases when you want to operate on both would
be pretty cumbersome and I imagine for that reason a lot of types would
continue to be written only with keyof even if at runtime they'd have no
problem handling symbols as well.

In the hypothetical case where you really need specifically string keys
you'd presumably get a compile error somewhere from typescript when you try
to pass or assign a string | symbol to a spot that requires a string. And
then you could rewrite keyof T to be string & keyof T to fix the error.

Is it actually very common to require specifically strings though when you
have a keyof T type? The only specific case I saw mentioned was the
return type of Object.keys, but that doesn't actually return a keyof T in
typescript. It just returns string[] because typescript can't be sure
that there won't be extra fields on T at runtime that it doesn't know
about.

—
You are receiving this because you commented.
Reply to this email directly, view it on GitHub
https://github.com/Microsoft/TypeScript/issues/20721#issuecomment-377408989,
or mute the thread
https://github.com/notifications/unsubscribe-auth/AhZXAKdgDX1VU-T4Dt6zpUwofGABfEaJks5tjXd1gaJpZM4RDcrW
.

Uh so fun fact I just learned: it turns out keyof T does in fact return any unique symbol on T as of 2.8??? I'm not sure if I just missed something or if this is unintentional or what but take for instance this code from earlier in this issue that didn't previously work:

const sym = Symbol();
const obj = { num: 0, str: 's', [sym]: sym };

function set <T extends object, K extends keyof T> (obj: T, key: K, value: T[K]): T[K] {
  return obj[key] = value;
}

const val = set(obj, 'str', '');
// string
const valB = set(obj, 'num', '');
// Expect type error
// Argument of type '""' is not assignable to parameter of type 'number'.
const valC = set(obj, sym, sym);
// ~~Unexpected type error~~
// ~~Argument of type 'unique symbol' is not assignable to parameter of type '"str" | "num"'.~~
// Not anymore in TS 2.8 

type KeyofObj = keyof typeof obj
// typeof sym | 'str' | 'num' in 2.8

type Values<T> = T[keyof T]

type ValuesOfObj = Values<typeof obj>
// string | number | symbol in 2.8

Playground link

I just confirmed that that didn't previously work in 2.7. As far as I can tell the only thing we are actually missing now is the ability to use symbols in mapped types. If it was really a breaking change to make keyof return symbols apparently it was quite a small one because it didn't even make it onto the breaking changes page and glancing at recent issues I didn't notice anything started about it.

That is weird. I wonder if this change to keyof behaviour was intentional.

The change doesn't seem in line with @weswigham's comment above, and as you point out breaking changes would normally be documented.

@weswigham It looks like you ended up changing keyof to return unique symbols in #22339? I tested before and after that PR and that's what changed the behavior. Wanted to point it out since I'm guessing it wasn't intentional based on your comments earlier in this issue and the fact that there is no mention of it in the PR.

You shouldn't rely on keyof returning numbers and unique symbols - it's definitely a bug because a keyof T is a subtype of string, and changing that would likely break _a lot_. We're going to look into fixing keyof's behavior (meaning it should omit symbols and map numbers to their string representation) while also adding a new operator that can correctly retrieve the declared keys of an object (we're toying with propkeyof T, but are open to suggestions).

Forgive me for being blunt, but I don't see how you can say changing it would likely break a lot when you already _did_ change it to no longer always only be a subtype of string and it didn't break enough for anyone to even notice. Any (edit: Some) code that for some reason actually requires keyof to always be a string is already breaking in 2.8:

const sym = Symbol()
const obj = { num: 0, str: 's', [sym]: true }
declare const objKeys: keyof typeof obj
const strObjKeys: string = objKeys
// error unique symbol is not assignable to string in 2.8

Playground link

So whatever (Edit: some) damage there would be is already being done, yet as far as I can tell there have been zero issues reported against it almost a month after it was merged and a week after 2.8 was released. That makes it hard for me to believe it's really that problematic of a breaking change.

If you do have to change it back though and add another operator instead, I'd prefer propof over propkeyof so that it at least has a fighting chance of being used regularly instead of the shorter and already common keyof.

Edit: Clarified that I was wrong when I thought any potential breaking changes would've already been happening in 2.8.

Forgive me for being blunt, but I don't see how you can say changing it would likely break a lot when you already did change it to no longer always only be a subtype of string and it didn't break enough for anyone to even notice

That's because

declare function log(x: string): void;
function f<T>(x: T, k: keyof T) {
    log(k);
}

still works (not may people have reason to call keyof on concrete types, methinks). If keyof can be a symbol or number, it should _not_, and that inconsistency is what's really a bug.

Fair enough I wasn't thinking of that. I still question how common that use case(trying to assign keyof T to a string for some reason) really is. Anywhere I'm declaring something to be a keyof T I'm doing it because I want to be able to do an indexed access with it and for that use case a symbol works just as well as a string.

If a propkeyof or propof operator is added how common will it be to still want plain keyof? The most basic use case for keyof is something like:

function getPropValue<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key]
}

Which would be much better typed with K extends propkeyof T because symbols will work with that code just as well as strings.

I think that we'd end up in an unfortunate situation where accurate types should normally be using propkeyof but it'll end up being far more common for people to just use keyof because of inertia and being slightly shorter to type.

Not the end of the world but it'd be nice to avoid. Especially since the workaround for the (still hypothetical to me) cases where it'd be a breaking change seems to be as simple as:

declare function log(x: string): void;
function f<T>(x: T, k: string & keyof T) {
    log(k);
}

I think that we'd end up in an unfortunate situation where accurate types should normally be using propkeyof but it'll end up being far more common for people to just use keyof because of inertia and being slightly shorter to type.

@kpdonn I think you make a valid point there. There's already a precedent for that happening with any.

I like your suggestion to have a single general operator (keyof), and narrow it with & string if you want just the string keys.

With #23592 keyof supports numeric literals and unique symbols.

I was worried about keyof including symbols, because narrowing with & string returns some really awful types. (typeof sym & string) | ("a" & string) | ("b" & string) in the simple instance of only three keys.

However with the introduction of condition types and Extract you can easily write Extract<keyof T, string> to get a return type of only the string keys.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

DanielRosenwasser picture DanielRosenwasser  Â·  3Comments

manekinekko picture manekinekko  Â·  3Comments

weswigham picture weswigham  Â·  3Comments

Antony-Jones picture Antony-Jones  Â·  3Comments

uber5001 picture uber5001  Â·  3Comments