GeezerVison is showing github.com — Support number and symbol named properties with keyof and mapped types by ahejlsberg · Pull Request #23592 · microsoft/TypeScript
Conversation
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.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
dinoboff, alexrock, yortus, j-oliveras, ikatyang, kobezzza, yanchith, pelotom, aboyton, SlurpTheo, and 19 more reacted with thumbs up emoji
kpdonn, artaommahe, j-oliveras, s-ve, mpawelski, benbraou, wizarrc, aaronbeall, begincalendar, amitbeck, and 9 more reacted with hooray emoji
amitbeck, Bnaya, chelmertz, janriemer, alitaheri, and Vaelor reacted with heart emoji
All reactions
-
👍
29 reactions
-
🎉
19 reactions
-
❤️
6 reactions
# Conflicts:
# src/compiler/checker.ts
# src/server/utilities.ts
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
This was referenced Apr 26, 2018
This was referenced Apr 30, 2018
This was referenced May 3, 2018
|
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
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
|
keyof T & string is a generally backcompat way to mostly accomplish the same thing as the extract type.
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
|
Jeff Lau (@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.
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
|
Wesley Wigham (@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.
Anders Hejlsberg (@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.
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
|
Wesley Wigham (@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>;
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
|
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?
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
|
Marta Galve (@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.
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
|
Anders Hejlsberg (@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[]
?
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
|
In both cases the key is a string.
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Reviewers
+1 more reviewer
Reviewers whose approvals may not affect merge requirements
Uh oh!
There was an error while loading. Please reload this page.
Assignees
No one assigned
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
|