9

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.

Andrey Tyukin
  • 43,673
  • 4
  • 57
  • 93
  • The type to the right of the `&` doesn't allow extra properties by itself. By using the (admittedly poorly-named) intersection type operator `&`, you're declaring a type that has all properties of all types in the so-called intersection, i.e. `[S in K]: V` as well as all `[s: string]: any`. – rob3c Nov 30 '20 at 04:07
  • 3
    There's nothing wrong with type system navel gazing lol, but do you have a practical problem you're trying to solve that led you to this question? Maybe posting some hypothetical code beyond just the signatures would help elucidate how you're hoping to leverage the type system for something more concrete. Do you hope to provide better intellisense on the caller side, within a function using the signature, convenient type declarations, etc? The idiomatic way to attack the problem may not require this particular approach. – rob3c Nov 30 '20 at 04:18
  • @rob3c Not sure what you mean by "navel gazing" here - I have never contributed to the typescript compiler. The original problem was quite practical: I had to add the weird-looking `[s: string]: any` to multiple types in order to deactivate the extra-property check where I didn't want it, and I was asking myself whether I can extract the `{ ... , [s: string]: any }` part into a separate type-definition, so as to reduce the amount of clutter. – Andrey Tyukin Dec 02 '20 at 00:12
  • Not sure why mention contributing to the TS compiler? Original question only had declarations/signatures without concrete usage samples, which could mean only theoretical rather than practical interest. I wasn't implying no practical application. As I said, theoretical questions are ok. I suggested describing hypothetical usage in case you were also trying to solve a practical problem. One reason is people often assume the form of a solution in their questions rather than stating problems more directly. Also the bounty asked about idiomatic usage. – rob3c Dec 02 '20 at 05:15
  • As for those function samples not compiling in the edit, you're passing object literals as params. Object literals are special and may only specify known properties. I believe the idea is that it's probably an error to throw away extra property data, since it doesn't occur anywhere else except there at that moment. However, you can pass variables with extra props to those functions as long as a subset of their props match the signature. I.e `const ab = { a: 42, b: 58 }` can be passed as `f(ab); g(ab); h(ab);` without compilation error. – rob3c Dec 02 '20 at 05:31

2 Answers2

3

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
artur grzesiak
  • 20,230
  • 5
  • 46
  • 56
  • Thanks for the answer. I think that the paragraphs 3 and 4 (second horizontal-line delimited block) are describing what I'm finding most perplexing. I've added an edit to my original question to emphasize that point. I'd appreciate if you could elaborate a bit further in that direction: why exactly does it happen that by restricting the type `{ [s in K]: V}` by intersecting it with `{[s: string]: any}` we obtain a parameter type that accepts more (and not fewer) possible arguments? – Andrey Tyukin Dec 02 '20 at 00:28
1

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 &.

Tiberiu Maran
  • 1,983
  • 16
  • 23
  • My problem with the intersection here is demonstrated by the following example: `function f(x: {a: number}){}; function g(y: {b: number}){}; function h(z: {a: number} & {b: number}){}; f({a: 42, b: 58}); g({a: 42, b: 58}); h({a: 42, b: 58});` The first two function invocations in this example do not compile. Thus, `{a: 42, b: 58}` is neither of type `{a: number}`, nor `{b: number}`, but somehow it ends up in the "intersection" of both `{a: number}` and `{b: number}`. Similarly with the mapped `{ [x in y]: z }`-types: the `&` somehow deactivates "extra properties"-check, but why? – Andrey Tyukin Dec 01 '20 at 23:58