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).
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 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 either string
or number
.
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
or number
aside from Proxy
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 to undefined
), 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 a string
index signature to represent a dictionary-like type, or a number
index signature to represent an array-like type. TypeScript 4.4 introduced support for symbol
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.
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 keys in
the key set K
. The new type variable is still in scope in the property value SomeTypeFunction<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 of string
literals or number
literals. You can also use string
or number
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.
Link to code
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With