Home > Mobile >  typescript: Create a [key,value type] tuple from an interface
typescript: Create a [key,value type] tuple from an interface

Time:02-17

I am trying to create a typescript tuple type (a pair), that can only hold values where the first value is a keyof some interface that holds valid operations as keys, and the type of the expected argument as value

I guess the code below will explain better what I am trying to accomplish:

interface IEventsAndArguments {
  ' ': number,
  '-': number,
  'change_name': string,
}

// Now I want to produce tuples,
// where the first value is in keyof IEventsAndArguments
// and the second value is of the corresponding type
// (but a type with 2 properties name and value would do as well)

// it works for function arguments
function myFunction<K extends keyof IEventsAndArguments>(x:K, y:IEventsAndArguments[K]) {}
myFunction(' ', 'a string'); // invalid as expected, ' ' needs a number
myFunction(' ', 4);
myFunction('change_name', 'John');
function myFunction2<K extends keyof IEventsAndArguments>({x, y}:{x:K, y:IEventsAndArguments[K]}) {}
myFunction2({x: ' ', y: 'another string'}); // invalid as expected
myFunction2({x: '-', y: 3});

// it doesn't work when I try to define a tuple type
type TTuple<K extends keyof IEventsAndArguments> = [K, IEventsAndArguments[K]];
// I would like the next tuple to be INVALID
// but it is valid since the second element is of type number | string
const tuple1:TTuple<keyof IEventsAndArguments> = [ ' ', 'hello' ]


// What I ultimately would want is something similar to this 
type TTuple2<K extends keyof IEventsAndArguments> = [
  K,
  (v:IEventsAndArguments[K]) => void
];
// I would like the next tuple to be VALID but it is invalid
// since the function argument must be of type number | string
const tuple2:TTuple2<keyof IEventsAndArguments> = [
  ' ',
  (t:number) => {},
];

Here is a playground link with the same code.

Is there a way to get this working? Can anyone explain why it behaves as I want with functions, but not when defining a new type?

Thanks.

EDIT: I think I found a mechanism that is 'good enough': since it works for function, we can use a function to produce a valid tuple!

// if we use a function to produce the tuple, we can produce 'valid'
// tuples, even if the tuple type itself is not as strict as we'd like...
function tuple<K extends keyof IEventsAndArguments>(
    a:K, f:(v:IEventsAndArguments[K]) => void
):TTuple2<K>
{
  return [a, f];
}

const tuple3 = tuple(' ', (v:number) => v   1); // valid, as expected
const tuple4 = tuple(' ', (v:string) => v   1); // invalid, also as expected

But the frustration remains: why does it work for functions (both parameters 'linked' to the same entry of the interface) but not when creating a new type? It would be nice if we could also make the type as strict as we want somehow, because right now you could still produce 'invalid' tuples by not using the function.

Oh: also important: I want this to be generic, so whatever entries people 'add' to the interface later on, it should keep working, so it should be assumed that the definition of the IEventsAndArguments is unknown and can be extended in other places.

CodePudding user response:

You want your TTuple type to either be a tuple of the form [" ", (v: number) => void], or of the form ["-", (v: number) => void], or of the form ["change_name", (v: string) => void]. You can get this without generics just by making TTuple a union of those types:

type TTuple = 
  [" ", (v: number) => void] | 
  ["-", (v: number) => void] | 
  ["change_name", (v: string) => void];

You can verify that this works:

let tuple: TTuple;
tuple = [' ', (t: number) => console.log(t.toFixed())]; // okay
tuple = ['change_name', (t: number) => console.log(t.toFixed())]; // error
tuple = ['change_name', (t: string) => console.log(t.toUpperCase())]; // okay

Of course you don't want to define TTuple manually; it would be preferable to have the compiler compute TTuple as a function of IEventsAndArguments.

One way to do this is to create a so-called "distributive object type" (terminology comes from microsoft/TypeScript#47109), where you first map over the IEventsAndArguments type to form a new object type with the same keys, but where the property value types are the corresponding tuple types, and then you immediately index into this mapped type with a union of the IEventsAndArguments keys, which produces a union of the tuples. Here's how it looks:

type TTuple = { [K in keyof IEventsAndArguments]: [
    K,
    (v: IEventsAndArguments[K]) => void
] }[keyof IEventsAndArguments];

Walking through that, the mapped type {[K in keyof]... } iterates over the " ", "-", and "change_name" keys to produce something like

{ 
   " ": [" ", (v: IEventsAndArguments[" "] )=> void],
   "-": ["-", (v: IEventsAndArguments["-"] )=> void],
   "change_name": ["change_name", (v: IEventsAndArguments["change_name"] )=> void]
}

which reduces to

{
   " ": [" ", (v: number) => void],
   "-": ["-", (v: number) => void],
   "change_name": ["change_name", (v: string) => void]
}

Then we index into that with " " | "-" | "change_name" which produces the union [" ", (v: number) => void] | ["-", (v: number) => void] | ["change_name", (v: string) => void] as desired.

Now if you add or change properties on the IEventsAndArguments type, the TTuple type will automatically change to accommodate it.

Playground link to code

  • Related