Home > Mobile >  In Typescript, how can I make a KeyBy generic type? Create an object keyed by a specific column from
In Typescript, how can I make a KeyBy generic type? Create an object keyed by a specific column from

Time:09-23

I have an array of objects type, and I'd like to convert it to an object keyed by a property in each of the objects. Conceptually similar to Lodash's KeyBy, but in types.

type Arr = [
    {slug: 'test-1', value: string},
    {slug: 'test-2', value: string},
    {slug: 'test-3', value: string},
]

type KeyBy<A extends object[], S extends keyof A[number]> = {
    //1. how do I get numeric indexes of A for iteration and mapping?
    //2. how do I convert those numeric indexes to the literal types of the matching property?
}

type Keyed = KeyBy<Arr, 'slug'>

/* desired result: 
{
    'test-1': {slug: 'test-1', value: string},
    'test-2': {slug: 'test-2', value: string},
    'test-3': {slug: 'test-3', value: string},
}
*/

I think the generic constraints I've put on the KeyBy type are appropriate, but it's not required to fit that.

CodePudding user response:

Yes, this is definitely possible:

type KeyedBy<A, S extends PropertyKey> = A extends Array<infer T>
  ? KeyedByInternal<T, S>
  : never;

type KeyedByInternal<T, S extends PropertyKey> = S extends keyof T ? {
  [K in T[S] as Extends<K, PropertyKey>]: Extends<T, Record<S, K>>
} : never;

type Extends<T1, T2> = T1 extends T2 ? T1 : never;

This gets you all the behavior you want, both for selectors that produce literal types as well as for selectors that produce "universe" types (e. g. string or number):

// Tests
type Arr = [
    {slug: 'test-1', value: string, id: 1},
    {slug: 'test-2', value: string, id: 2},
    {slug: 'test-3', value: string, id: 3},
]

type Keyed = KeyedBy<Arr, 'slug'>
/* 
{
    "test-3": {
        slug: 'test-3';
        value: string;
        id: 3;
    };
    "test-1": {
        slug: 'test-1';
        value: string;
        id: 1;
    };
    "test-2": {
        slug: 'test-2';
        value: string;
        id: 2;
    };
}
*/


type Keyed2 = KeyedBy<Arr, 'id'>
/* 
{
    3: {
        slug: 'test-3';
        value: string;
        id: 3;
    };
    1: {
        slug: 'test-1';
        value: string;
        id: 1;
    };
    2: {
        slug: 'test-2';
        value: string;
        id: 2;
    };
}
*/


// IT WORKS FOR NON-LITERAL TYPES TOO!
type Keyed3 = KeyedBy<Arr, 'value'>
/*
{
    [x: string]: {
        slug: 'test-1';
        value: string;
        id: 1;
    } | {
        slug: 'test-2';
        value: string;
        id: 2;
    } | {
        slug: 'test-3';
        value: string;
        id: 3;
    };
}
*/

The real insight is that we can filter the right hand side of our union type with Record<S, K> (where S is our literal type representing a Selector into the object type and K is the current key we're computing in our mapped type) in order to focus the value to the branch(es) of the union type we're dealing with that match our selector.

CodePudding user response:

After spending a bit more time on this, I have a working solution to solving this using numeric indexes, although not quite as robust as @Sean's solution, in that it doesn't support universal types, but it is perhaps easier to follow, at least for me.

/* helpers */
type IndecesOfArr<Arr extends any[]> = Exclude<Partial<Arr>['length'], Arr['length'] | undefined>;
type Extends<T1, T2> = T1 extends T2 ? T1 : never;

type Arr = [
    {slug: 'test-1', value: string},
    {slug: 'test-1', value: string, test: number}, //matching keys would be unioned
    {slug: 'test-2', value: string},
    {slug: 'test-3', value: string},
    {slug: ['non-indexable'], value: string}, //should be excluded from result
]

type KeyBy<A extends object[], S extends keyof A[number]> = {
    [I in IndecesOfArr<A> as Extends<A[I][S], PropertyKey>]: A[I]
}

type Keyed = KeyBy<Arr, 'slug'>

/* expected result: 
{
    'test-1': {slug: 'test-1', value: string} 
        | { slug: 'test-1', value: string, test: number},
    'test-2': {slug: 'test-2', value: string},
    'test-3': {slug: 'test-3', value: string},
}
*/

The IndecesOfArr type works because of typescript's treatment of Arr['length']. When given a literal array, it actually tells you the literal length instead of just number. If you wrap the Arr in Partial<Arr>, it tell you all the possible lengths (0 - length) as a union. We're excluding the full length number to get a zero based union of all the lengths.

In the mapping index, we're leveraging these indeces, and casting the index to a key with the value of that index's S property literal value. Then we assign that key to value of that index in the original array.

Playground Link

  • Related