Typescript: index signatures in mapped type

2k views Asked by At

How can I take the type { 'k': number, [s: string]: any } and abstract over 'k' and number? I would like to have a type alias T such that T<'k', number> gives the said type.


Consider the following example:

function f(x: { 'k': number, [s: string]: any }) {}                           // ok
type T_no_params = { 'k': number, [s: string]: any };                         // ok
type T_key_only<k extends string> = { [a in k]: number };                     // ok
type T_value_only<V> = { 'k': V, [s: string]: any};                           // ok
type T_key_and_index<k extends string, V> = { [a in k]: V, [s: string]: any };// ?
  • Using { 'k': number, [s: string]: any} directly as type of the parameter of the function f works.
  • Using the [s: string]: any indexed part in type-alias works
  • Using k extends string in type-alias also works
  • When combining the k extends string with the [s: string]: any in same type-alias, I get a parse error (not even a semantic error, it doesn't even seem to be valid syntax).

This here seems to work:

type HasKeyValue<K extends string, V> = { [s: string]: any } & { [S in K]: V }

but here, I can't quite understand why it does not complain about extra properties (the type on the right side of the & should not allow objects with extra properties).


EDIT:

It has been mentioned several times in the answers that the & is the intersection operator, which is supposed to behave similarly to the set-theoretic intersection. This, however, is not so when it comes to the treatment of the extra properties, as the following example demonstrates:

function f(x: {a: number}){};
function g(y: {b: number}){};
function h(z: {a: number} & {b: number}){};

f({a: 42, b: 58});  // does not compile. {a: 42, b: 58} is not of type {a: number}
g({a: 42, b: 58});  // does not compile. {a: 42, b: 58} is not of type {b: number}
h({a: 42, b: 58});  // compiles!

In this example, it seems as if the {a: 42, b: 58} is neither of type {a: number}, nor of type {b: number}, but it somehow ends up in the intersection {a: number} & {b: number}. That's not how set-theoretical intersection works.

That's exactly the reason why my own &-proposal looked so suspicious to me. I'd appreciate if someone could elaborate how "intersecting" a mapped type with { [s: string]: any } could make the type "bigger" instead of making it smaller.


I've seen the questions

but those did not seem directly related, albeit having a similar name.

2

There are 2 answers

1
artur grzesiak On BEST ANSWER

type HasKeyValue<K extends string, V> = { [s: string]: any } & { [S in K]: V } is the correct way to define the type you are after. But one thing to know is (paraphrasing deprecated flag: keyofStringsOnly):

keyof type operator returns string | number instead of string when applied to a type with a string index signature.

I do not know a method to restrict index to be just the string type and not string | number. Actually allowing number to access string index seems a reasonable thing, as it is in line how Javascript works (one can always stringify a number). On the other hand you cannot safely access a number index with a string value.


The & type operator works similarly to set theoretic intersection - it always restricts set of possible values (or leaves them unchanged, but never extends). In your case the type excludes any non-string-like keys as index. To be precise you exclude unique symbol as index.

I think your confusion may come from the way how Typescript treats function parameters. Calling a function with explicitly defined parameters behaves differently from passing parameters as variables. In both cases Typescript makes sure all parameters are of correct structure/shape, but in the latter case it additionally does not allow extra props.


Code illustrating the concepts:

type HasKeyValue<K extends string, V> = { [s: string]: any } & { [S in K]: V };
type WithNumber = HasKeyValue<"n", number>;
const x: WithNumber = {
  n: 1
};

type T = keyof typeof x; // string | number
x[0] = 2; // ok - number is a string-like index
const s = Symbol("s");
x[s] = "2"; // error: cannot access via symbol

interface N {
  n: number;
}

function fn(p: N) {
  return p.n;
}

const p1 = {
  n: 1
};

const p2 = {
  n: 2,
  s: "2"
};

fn(p1); // ok - exact match
fn(p2); // ok - structural matching: { n: number } present;  additional props ignored
fn({ n: 0, s: "s" }); // error: additional props not ignore when called explictily
fn({}); // error: n is missing

EDIT

Object literals - explicitly creating object of some shape like const p: { a: number} = { a: 42 } is treated by Typescript in a special way. In opposite to regular structural inference, the type must be matched exactly. And to be honest it makes sense, as those extra properties - without additional possibly unsafe cast - are inaccessible anyway.

[...] However, TypeScript takes the stance that there’s probably a bug in this code. Object literals get special treatment and undergo excess property checking when assigning them to other variables, or passing them as arguments. If an object literal has any properties that the “target type” doesn’t have, you’ll get an error. [...] One final way to get around these checks, which might be a bit surprising, is to assign the object to another variable.

TS Handbook

The other option to get around this error is to... intersect it with { [prop: string]: any }.

More code:

function f(x: { a: number }) {}
function g(y: { b: number }) {}
function h(z: { a: number } & { b: number }) {}

f({ a: 42, b: 58 } as { a: number }); // compiles - cast possible, but `b` inaccessible anyway
g({ a: 42 } as { b: number }); // does not compile - incorrect cast; Conversion of type '{ a: number; }' to type '{ b: number; }' may be a mistake
h({ a: 42, b: 58 }); // compiles!

const p = {
  a: 42,
  b: 58
};

f(p); // compiles - regular structural typing
g(p); // compiles - regular structural typing
h(p); // compiles - regular structural typing

const i: { a: number } = { a: 42, b: 58 }; // error: not exact match
f(i); // compiles
g(i); // error
h(i); // error
1
Tiberiu Maran On

Here's a way of reasoning about the intersection operator. Maybe it helps:

type Intersection = { a: string } & { b: number }

You can read Intersection as "an object that has a property a of type string and a property b of type number". That happens to also describe this simple type:

type Simple = { a: string; b: number }

And the two types are compatible. You can replace one with the other for almost all purposes.

I hope this explains why HasKeyValue is indeed the same as the type you were trying to define.

As for why T_key_and_index doesn't work, it's because the first part, [a in k]: V, defines a mapped type, and in the definition of a mapped type you can't have extra properties. If you need to add extra properties to a mapped type, you can create a type intersection with &.