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

Support number and symbol named properties with keyof and mapped types #23592

Merged
merged 27 commits into from
Apr 23, 2018

Conversation

ahejlsberg
Copy link
Member

@ahejlsberg ahejlsberg commented Apr 20, 2018

This PR adds support for number and symbol named properties in index types and mapped types. Previously, the keyof operator and mapped types only supported string named properties. Since this is technically a breaking change, the PR includes a --keyofStringsOnly compiler option that can be used to disable the new behavior in projects in which it causes errors (we expect this to be relatively rare).

Changes include:

  • An index type keyof T for some type T is a subtype of string | number | symbol. This is a breaking change as keyof T is no longer assignable to just string.
  • A mapped type { [P in K]: XXX } permits any K assignable to string | number | symbol.
  • In a for...in statement for an object of a generic type T, the inferred type of the iteration variable was previously keyof T but is now Extract<keyof T, string>. (In other words, the subset of keyof T that includes only string-like values.)

Given an object type X, keyof X is resolved as follows:

  • If X contains a string index signature, keyof X is a union of string, number, and the literal types representing symbol-like properties, otherwise
  • If X contains a numeric index signature, keyof X is a union of number and the literal types representing string-like and symbol-like properties, otherwise
  • keyof X is a union of the literal types representing string-like, number-like, and symbol-like properties.

The string-like properties of an object type are those declared using an identifier, a string literal, or a computed property name of a string literal type. The number-like properties of an object type are those declared using a numeric literal or computed property name of a numeric literal type. The symbol-like properties of an object type are those declared using a computed property name of a unique symbol type (see #15473).

In a mapped type { [P in K]: XXX }, each string literal type in K introduces a property with a string name, each numeric literal type in K introduces a property with a numeric name, and each unique symbol type in K introduces a property with a unique symbol name. Furthermore, if K includes type string, a string index signature is introduced, and if K includes type number, a numeric index signature is introduced.

Some examples:

const c = "c";
const d = 10;
const e = Symbol();

const enum E1 { A, B, C }
const enum E2 { A = "A", B = "B", C = "C" }

type Foo = {
    a: string;       // String-like name
    5: string;       // Number-like name
    [c]: string;     // String-like name
    [d]: string;     // Number-like name
    [e]: string;     // Symbol-like name
    [E1.A]: string;  // Number-like name
    [E2.A]: string;  // String-like name
}

type K1 = keyof Foo;  // "a" | 5 | "c" | 10 | typeof e | E1.A | E2.A
type K2 = Extract<keyof Foo, string>;  // "a" | "c" | E2.A
type K3 = Extract<keyof Foo, number>;  // 5 | 10 | E1.A
type K4 = Extract<keyof Foo, symbol>;  // typeof e

Since keyof now reflects the presence of a numeric index signature by including type number in the key type, mapped types such as Partial<T> and Readonly<T> work correctly when applied to object types with numeric index signatures:

type Arrayish<T> = {
    length: number;
    [x: number]: T;
}

type ReadonlyArrayish<T> = Readonly<Arrayish<T>>;

declare const map: ReadonlyArrayish<string>;
let n = map.length;
let x = map[123];  // Previously of type any (or an error with --noImplicitAny)

Furthermore, with the keyof operator's support for number and symbol named keys, it is now possible to abstract over access to properties of objects that are indexed by numeric literals (such as numeric enum types) and unique symbols.

const enum Enum { A, B, C }

const enumToStringMap = {
    [Enum.A]: "Name A",
    [Enum.B]: "Name B",
    [Enum.C]: "Name C"
}

const sym1 = Symbol();
const sym2 = Symbol();
const sym3 = Symbol();

const symbolToNumberMap = {
    [sym1]: 1,
    [sym2]: 2,
    [sym3]: 3
};

type KE = keyof typeof enumToStringMap;     // Enum (i.e. Enum.A | Enum.B | Enum.C)
type KS = keyof typeof symbolToNumberMap;   // typeof sym1 | typeof sym2 | typeof sym3

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

let x1 = getValue(enumToStringMap, Enum.C);  // Returns "Name C"
let x2 = getValue(symbolToNumberMap, sym3);  // Returns 3

Fixes #13715.
Fixes #14359.
Fixes #20721.
Fixes #21983.
Fixes #22105.

@rbuckton
Copy link
Member

@lkgarrison, that package has not yet been updated to use unique symbol, though a PR will be posted shortly.

ethanresnick added a commit to ethanresnick/json-api that referenced this pull request May 3, 2018
@UselessPickles
Copy link

Any recommendations for how library developers should proceed with this breaking change?

For example, I maintain a project available via npm, written in typescript, and distribute its type definitions file (which includes generic types and method signatures involving keyof T) with the npm package. It currently depends on keyof only returning string types, and, up til now, was compatible with all TypeScript versions since 2.3.

I could modify the source code to compile in TypeScript 2.9 by replacing all instances of keyof T with Extract<keyof T, string>, but then the type definitions file that I generate/distribute will cause compiler errors for anyone attempting to use my npm package with TypeScript versions 2.3-2.7 (because Extract is new for 2.8).

Are there any tricks I can use to define my generic classes/methods involving keyof T so that they are valid for for both version 2.9 and pre-2.9, without requiring all users of my package to use the --keyofStringsOnly compiler option?

Is there some way to conditionally define a generic type differently depending on TypeScript versions. I basically need (example inspired by C compiler directives):

#if TYPESCRIPT_VERSION >= 2.9
type ExtractStringKeysType<T> = Extract<keyof T, string>;
#else
type ExtractStringKeysType<T> = keyof T;
#endif

@weswigham
Copy link
Member

weswigham commented May 22, 2018

keyof T & string is a generally backcompat way to mostly accomplish the same thing as the extract type.

@ahejlsberg
Copy link
Member Author

@UselessPickles I recommend using keyof any as the constraint for type parameters that represent property names (i.e. change from T extends string to T extends keyof any). keyof any resolves to string in previous versions and string | number | symbol in 2.9 so it is effectively a conditional compilation construct.

@UselessPickles
Copy link

@weswigham Thanks, I'll give that a try and report back to confirm that it works (at least for TS versions 2.3-2.9). If this is the solution, I think it should be added as a suggestion to the breaking changes documentation.

@ahejlsberg That is a very useful tip (maybe should be added to the breaking changes documentation?), but actually seems to be opposite of what I was looking for (a way to consistently get only the string-like key types of some type T in all TS versions).

Some more context...

The project I'm trying to future-proof for TS 2.9: https://github.com/UselessPickles/ts-enum-util

At the core of the problem is that I use this type as a constraint to support any "enum-like" types (objects with string keys and number | string values), but in a way that allows the exact keys and exact value types to be inferred properly for string enums, etc):

export type EnumLike<V extends number | string, K extends string> = {
    [P in K]: V
};

Then the main generic class that uses that type constraint:

export class EnumWrapper<
    V extends number | string = number | string,
    T extends EnumLike<V, keyof T> = any
>

Which allows me to have a very strictly typed utility wrapper for any enum that knows the exact type of values in the enum (V), and the exact union of string literal enum names/keys (keyof T).

I think changing my constraint in EnumWrapper to T extends EnumLike<V, keyof T & string> is more appropriate than changing the constraint in EnumLike to K extends keyof any, because enums should only have string keys.

@UselessPickles
Copy link

@weswigham Unfortunately, the keyof T & string trick doesn't work for me :(. It only works in TS 2.9.

Prior to TS 2.9, the & string part sticks around as part of the type, and prevents me from using it to index into type T to get the type of the properties in T. For example:

interface Test {
    A: number;
    B: string;
}

type StringKeys<T> = keyof T & string;
type StringKeyValues<T> = T[StringKeys<T>];

// expect: "A" | "B"
// got: ("A" & string) | ("B" & string)
type Keys = StringKeys<Test>;

// expect: number | string
// got: any
type Values = StringKeyValues<Test>;

@seaBubble
Copy link

I am trying to set an enum as the key of an interface in 2.7.2:
enum TestEnum {
A: 'a',
B: 'a'
}
interface TestCounter {
[key: TestEnum]: number;
}

so I can have objects as {'a':1}

Am I doing something wrong?

@UselessPickles
Copy link

@seaBubble The type of the index signature key can only be number or string. You have to use a mapped type to create an interface whose keys are the values of a string enum:

enum TestEnum {
  A = 'a',
  B = 'b'
}

type TestCounter = { [P in TestEnum]: number };

const testCounter: TestCounter = {
    [TestEnum.A]: 0,
    [TestEnum.B]: 0
};

This can be further generalized with generics, and can also work with string literal unions. Check out my generic strictly typed string visitor/mapper utility for more advanced generic examples: https://github.com/UselessPickles/ts-string-visitor

In particular, look at the StringMapperCore interface in src/StringMapper.ts.

@zpdDG4gta8XKpMCd
Copy link

zpdDG4gta8XKpMCd commented May 27, 2018

@ahejlsberg will there be support for

enum E = { A, B }
declare const keyedByEnum: { [ P in E ]: any; }
for (const key in keyedByEnum) {
   // key needs to be of type E
}

and

const keys = Object.keys(keyedByEnum); // keys need to be E[]

?

@mhegazy
Copy link
Contributor

mhegazy commented May 27, 2018

In both cases the key is a string.

@zpdDG4gta8XKpMCd
Copy link

zpdDG4gta8XKpMCd commented May 27, 2018 via email

@microsoft microsoft locked and limited conversation to collaborators Jul 31, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Breaking Change Would introduce errors in existing code
Projects
None yet