Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Is it possible for TypeScript to infer keys from a dynamic object?

What I'm trying to achieve here is intellisense/autocomplete for an object that's been generated from an array - something like an Action Creator for Redux, an array of strings (string[]) that can be reduced to an object with shape { [string]: string }.

For example:

const a = ['ONE', 'TWO', 'THREE'];

const b = a.reduce((acc, type) => ({ ...acc, [type]: type }), {});

console.log(b);
// Output: b = { ONE: 'ONE', TWO: 'TWO', THREE: 'THREE' };

I've managed to get TypeScript to understand that much, using the below. TypeScript understands that the keys are strings, but does not know what they are.

interface ITypesReturnObject { [s: string]: string }

Has anyone worked out a way to inform TypeScript that the keys on the object are equal to the strings in the array?

Any help would be greatly appreciated.

like image 662
Brody McKee Avatar asked Oct 26 '18 10:10

Brody McKee


1 Answers

(Assuming you use TS3.0 or greater in the following)

TypeScript supports the concept of string literal types as well as tuple types, so it is possible to get your value a to have the type ['ONE', 'TWO', 'THREE'] as well as the value ['ONE', 'TWO', 'THREE'], like this:

const a: ['ONE', 'TWO', 'THREE'] = ['ONE', 'TWO', 'THREE'];

(A less redundant way to get this to happen will come later):

Then you can represent the intended type of b as a mapping from keys to values that match the key exactly, using a mapped type:

type SameValueAsKeys<KS extends string[]> = { [K in KS[number]]: K };

which could be used like this:

const b: SameValueAsKeys<typeof a> = a.reduce((acc, type) => ({ ...acc, [type]: type }), {} as any);
b.ONE; // property type is "ONE"
b.TWO; // property type is "TWO"
b.THREE; // property type is "THREE"

Notice how the compiler knows that b has three keys, "ONE", "TWO", and "THREE", and that the values are the same as the keys.


So TypeScript certainly supports this type of dynamic typing. Unfortunately it's a bit tedious to use as I showed it above. One way to make this less annoying is to add some helper functions that allow the compiler to infer the proper types, or at least hide the type assertions in a library where the developer won't have to worry about them.

First, for a... the compiler doesn't infer tuple types, and it also tends to widen string literals to the string type except in certain instances. Let's introduce a helper function named stringTuple():

function stringTuple<T extends string[]>(...args: T) { return args; }

This infers a tuple type from rest arguments. Now we can use it to make a without typing redundant string values:

const a = stringTuple("ONE", "TWO", "THREE");

Next, let's introduce a function which takes a list of strings and returns an object whose keys are those strings and whose values match the strings:

function keyArrayToSameValueAsKeys<T extends string[]>(keys: T): SameValueAsKeys<T>;
function keyArrayToSameValueAsKeys(keys: string[]): { [k: string]: string } {
  return keys.reduce((acc, type) => ({ ...acc, [type]: type }), {});
}

Here we are using your same code with reduce, but we're hiding it inside its own function and using a single overload call signature to represent the intended output type. Now, we can get b like this:

const b = keyArrayToSameValueAsKeys(a);
b.ONE; // property type is "ONE"
b.TWO; // property type is "TWO"
b.THREE; // property type is "THREE"

If you put stringTuple() and keyArrayToSameValueAsKeys() in a library, the user can use them without too much trouble:

const otherObject = keyArrayToSameValueAsKeys(stringTuple("x", "y", "z"));
// const otherObject: {X: "X", Y: "Y", Z: "Z"}

Or you can merge them together like this:

function keysToSameValueAsKeys<T extends string[]>(...keys: T): { [K in T[number]]: K };
function keysToSameValueAsKeys(keys: string[]): { [k: string]: string } {
  return keys.reduce((acc, type) => ({ ...acc, [type]: type }), {});
}

And then get the output in one call, like this:

const lastOne = keysToSameValueAsKeys("tic", "tac", "toe");
// const lastOne: {tic: "tic", tac: "tac", toe: "toe"};

Okay, hope that's of some help. Good luck!

like image 108
jcalz Avatar answered Sep 21 '22 23:09

jcalz