tenbits
tenbits

Reputation: 8008

Deep Pick by Interface

Assume we have an object

let obj = {
    foo: 'foo',
    bar: 'bar',
    letters: {
        a: 'some a',
        b: 'some b',
    }
}

and we want to do a deep pick by a definition:

let projection = {
    foo: 1,
    letters: {
        b: 1
    }
}

and it would produce:

let result = {
    foo: 'foo',
    letters: {
        b: 'some b'
    }
}

The question is not how to do this in javascript - it is quite straightforward. But how the TypeScript definition would look like.

type TProjection<T extends object, U extends keyof T> = {
    [key in U]?: T[key] extends object ? TProjection<T[key], keyof T[key]> : number;
}
function pick<T extends object, U extends keyof T>(
    obj: T,
    projection: TProjection<T, U>
): Pick<T, U> {
    return null;
}

This results to top-level pick only, the letter field includes both a and b.

Upvotes: 1

Views: 531

Answers (1)

jcalz
jcalz

Reputation: 328503

The implementation of your pick() function might matter a bit, since there are probably edge cases in the typing I will propose here that you'll need to test. Hopefully you can use this as a starting point and tweak to get edge cases.

First it's useful to describe a Projection<T> which is the acceptable type of projection values for an obj of type T:

type Projection<T extends object> = {
  [K in keyof T]?: T[K] extends object ? Projection<T[K]> : number
};

(We could say that number should be 1 but then you'll need something like a const assertion for your projection variable.)

Given an object type T and a projection type P that extends Projection<T>, we can now represent DeepPickByProjection<T, P>:

type DeepPickByProjection<T extends object, P extends Projection<T>> = {
  [K in Extract<keyof T, keyof P>]: (
    P[K] extends number ? T[K] :
    T[K] extends object ? DeepPickByProjection<T[K], P[K]> :
    never
  ) } extends infer O ? { [K in keyof O]: O[K] } : never;

Basically we are walking through the common keys of T and P. If the property in P is a number, we output the property from T. If it is an object, then we recurse down into T[K] and P[K] and repeat. The only part that might look weird is the extends infer O... bit at the end. This is a trick to force the compiler to eagerly evaluate the output type instead of deferring it. If you want an output type like {foo: string; letters: {b: string}} instead of DeepPickByProjection<UglyTypeOne, UglyTypeTwo>, you need something like this trick. See this question for details about how it works.

Okay, let's see if the typing works:

function pick<T extends object, P extends Projection<T>>(
  obj: T,
  projection: P
): DeepPickByProjection<T, P> {
  return null!;
}

const result = pick(obj, projection);
/* const result: {
    foo: string;
    letters: {
        b: string;
    };
} */

Looks good. The output type of result is the same as the input type of obj filtered by the projection. So hooray.

Remember though there might be lots of edge cases. I'd be concerned about optional properties, union properties, and array-valued properties for starters, as well as making sure someone wouldn't want to pass in something like {letters: 1} meaning "copy the whole subtree here", since the above typing prohibits that, I think. As I said, this should be thought of as a launching point and not as a production-ready drop-in for your use case.

Playground link to code

Upvotes: 2

Related Questions