'Combining generics with index type
Since this works:
const f = <T extends string>(x: T) => x;
f("");
interface Dictionary<T> { [key: string]: T; }
const dict: Dictionary<number> = { a: 1 };
I was expecting following code to work as well:
interface MyRecord<Key extends string, Value> { [_: Key]: Value };
but compiler reports on _
:
An index signature parameter type must be 'string' or 'number'.
Changing Key extends string
to Key extends string | number
does nothing (same error).
What is the reason why it fails and how would look a correct solution? (Preferably without using Any
and similar.)
Edit1:
type XY = 'x' | 'y';
const myXY: XY = 'x';
const myString: string = myXY;
Since this works, I was assuming same holds with indexed types (subset of string
can pose in a role of string
which is required by indexed type).
Solution 1:[1]
Let's talk about index signature types and mapped types. They have similar syntax and do similar-ish things, but they're not the same. Here are the similarities:
They are both object types representing a range of properties
Syntax: both index signatures and mapped types use bracketed keylike notation within an object type, as in
{[Some Key-like Expression]: T}
Now for the differences:
INDEX SIGNATURES
Index signatures describe part of an object type or interface representing an arbitrary number of properties of the same type, with keys from a certain key type. Currently, these key type can only be exactly string
, number
, or symbol
, or a "pattern template literal" types as implemented in ms/TS#40598 like `foo_${string}`
, or a union of these.
Syntax: The syntax for an index signature looks like this:
type StringIndex<T> = {[dummyKeyName: string]: T} type NumberIndex<T> = {[dummyKeyName: number]: T}
There is a dummy key name (
dummyKeyName
above) which can be whatever you want and does not have any meaning outside the brackets, followed by a type annotation (:
) of eitherstring
ornumber
.Part of an object type: an index signature can appear alongside other properties in an object type or interface:
interface Foo { a: "a", [k: string]: string }
Arbitrary number of properties: an object of an indexable type is not required to have a property for every possible key (which is not even really possible to do for
string
ornumber
aside fromProxy
objects). Instead, you can assign an object containing an arbitrary number of such properties to an indexable type. Note that when you read a property from an indexable type, the compiler will assume the property is present (as opposed toundefined
), even with--strictNullChecks
enabled, even though this is not strictly type safe. Example:type StringDict = { [k: string]: string }; const a: StringDict = {}; // no properties, okay const b: StringDict = { foo: "x", bar: "y", baz: "z" }; // three properties, okay const c: StringDict = { bad: 1, okay: "1" }; // error, number not assignable to boolean const val = a.randomPropName; // string console.log(val.toUpperCase()); // no compiler warning, yet // "TypeError: val is undefined" at runtime
Properties of the same type: all of the properties in an index signature must be of the same type; the type cannot be a function of the specific key. So "an object whose property values are the same as their keys" cannot be represented with an index signature as anything more specific than
{[k: string]: string}
. If you want a type that accepts{a: "a"}
but rejects{b: "c"}
, you can't do that with an index signature.Only
string
,number
,symbol
, or a pattern template literal is allowed as the key type: you can use astring
index signature to represent a dictionary-like type, or anumber
index signature to represent an array-like type. TypeScript 4.4 introduced support forsymbol
and pattern template literals, and unions of these.
You can't narrow the index signature to a particular set of string
or number
literals like "a"|"b"
or 1|2
. (Your reasoning about why it should accept a narrower set is plausible but that's not how it works. The rule is that no member of an index signature parameter type can be a "singleton" or "unit" literal type.
MAPPED TYPES
A mapped type on the other hand describes an entire object type, not an interface, representing a particular set of properties of possibly varying types, with keys from a certain key type. You can use any key type for this, although a union of literals is most common (if you use string
or number
, then that part of the mapped type turns into... guess what? an index signature!) In what follows I will use only a union of literals as the key set.
Syntax: The syntax for a mapped type looks like this:
type Mapped<K extends keyof any> = {[P in K]: SomeTypeFunction<P>}; type SomeTypeFunction<P extends keyof any> = [P]; // whatever
A new type variable
P
is introduced, which iterates over each member of the union of keysin
the key setK
. The new type variable is still in scope in the property valueSomeTypeFunction<P>
, even though it's outside the brackets.An entire object type: a mapped type is the entire object type. It cannot appear alongside other properties and cannot appear in an interface. It's like a union or intersection type in that way:
interface Nope { [K in "x"]: K; // errors, can't appear in interface } type AlsoNope = { a: string, [K in "x"]: K; // errors, can't appear alongside other properties }
A particular set of properties: unlike index signatures, a mapped type must have exactly one property per key in the key set. (An exception to this is if the property happens to be optional, either because it's mapped from a type with optional properties, or because you modify the property to be optional with the
?
modifier):type StringMap = { [K in "foo" | "bar" | "baz"]: string }; const d: StringMap = { foo: "x", bar: "y", baz: "z" }; // okay const e: StringMap = { foo: "x" }; // error, missing props const f: StringMap = { foo: "x", bar: "y", baz: "z", qux: "w" }; // error, excess props
Property types may vary: because the iterating key type parameter is in scope in the property type, you can vary the property type as a function of the key, like this:
type SameName = { [K in "foo" | "bar" | "baz"]: K }; /* type SameName = { foo: "foo"; bar: "bar"; baz: "baz"; } */
Any key set may be used: you are not restricted to
string
,number
,symbol
or pattern template literals. You can use any set ofstring
literals ornumber
literals. You can also usestring
ornumber
in there, but you immediately get an index signature when that happens:type AlsoSameName = { [K in "a" | 1]: K }; /* type AlsoSameName = { a: "a"; 1: 1; } */ const x: AlsoSameName = { "1": 1, a: "a" } type BackToIndex = { [K in string]: K } /* type BackToIndex = { [x: string]: string; }*/ const y: BackToIndex = { a: "b" }; // see, widened to string -> string
And since any key set may be used, it can be generic:
type MyRecord<Key extends string, Value> = { [P in Key]: Value };
So that's how you would make MyRecord
. It can't be an indexable type; only a mapped type. And note that the built-in Record<K, T>
utility type is essentially the same (it allows K extends string | number | symbol
), so you might want to use that instead of your own.
Solution 2:[2]
You can use the typescript's Record<Key, Value>
utility instead of the index signature.
interface MyObject<K extends string = string> {
someProperty: Record<K, any>;
}
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
Solution | Source |
---|---|
Solution 1 | |
Solution 2 | Vahid |