1

Is it possible in Typescript to create subset of type similar to this?

type Schema = {
  user: {
    name: string;
    age: number;
    profile: {
      isCool?: boolean;
    };
  };
};

const wantedSubset = {
  user: {
    name: true,
    profile: {
      isCool: true
    }
  }
};

type ExpectedType = {
  user: {
    name: string;
    profile: {
      isCool?: boolean;
    };
  };
};

const result: ExpectedType = desiredCode(wantedSubset);

In case it's not clear I want to have desiredCode function that given object wantedSubset will return anything but typed as recursive use of declare function pick<T, K extends keyof T>(obj: T, ...keys: K[]): Pick<T, K>;

I would like to apply this Pick recursively on Schema but I don't know how I can recursively "refer" to Schema type.

I tried something like this:

function gql<Q, S, QK extends keyof Q, SK extends keyof S, K extends SK & QK>(
  query: Q | true,
  schema: S
) {
  if (query === true) return (query as unknown) as S;
  const keys = Object.keys(query) as K[];
  const result = {} as Pick<S, K>;
  keys.forEach(k => {
    result[k] = gql(query[k], schema[k]);
  });

  return result;
}

const result = gql(wantedSubset, wantedSubset as unknown as Schema)

but it doesn't work the way I would like it to. It simply return result of Pick instead of applying it recursively.

Basically the problem is how to build object dynamically so typescript will be able to infer return value.

https://www.typescriptlang.org/play/#src=type%20Schema%20%3D%20%7B%0D%0A%20%20user%3A%20%7B%0D%0A%20%20%20%20name%3A%20string%3B%0D%0A%20%20%20%20age%3A%20number%3B%0D%0A%20%20%20%20profile%3A%20%7B%0D%0A%20%20%20%20%20%20isCool%3F%3A%20boolean%3B%0D%0A%20%20%20%20%7D%3B%0D%0A%20%20%7D%2C%0D%0A%20%20foo%3A%20%7B%0D%0A%20%20%20%20bar%3A%20string%3B%0D%0A%20%20%7D%0D%0A%7D%3B%0D%0A%0D%0Aconst%20wantedSubset%20%3D%20%7B%0D%0A%20%20user%3A%20%7B%0D%0A%20%20%20%20name%3A%20true%2C%0D%0A%20%20%20%20profile%3A%20%7B%0D%0A%20%20%20%20%20%20isCool%3A%20true%0D%0A%20%20%20%20%7D%0D%0A%20%20%7D%0D%0A%7D%3B%0D%0A%0D%0A%0D%0Afunction%20gql%3CQ%2C%20S%2C%20QK%20extends%20keyof%20Q%2C%20SK%20extends%20keyof%20S%2C%20K%20extends%20SK%20%26%20QK%3E(%0D%0A%20%20query%3A%20Q%20%7C%20true%2C%0D%0A%20%20schema%3A%20S%0D%0A)%20%7B%0D%0A%20%20if%20(query%20%3D%3D%3D%20true)%20return%20(query%20as%20unknown)%20as%20S%3B%0D%0A%20%20const%20keys%20%3D%20Object.keys(query)%20as%20K%5B%5D%3B%0D%0A%20%20const%20result%20%3D%20%7B%7D%20as%20Pick%3CS%2C%20K%3E%3B%0D%0A%20%20keys.forEach(k%20%3D%3E%20%7B%0D%0A%20%20%20%20result%5Bk%5D%20%3D%20gql(query%5Bk%5D%2C%20schema%5Bk%5D)%3B%0D%0A%20%20%7D)%3B%0D%0A%0D%0A%20%20return%20result%3B%0D%0A%7D%0D%0A%0D%0Aconst%20result%20%3D%20gql(wantedSubset%2C%20wantedSubset%20as%20unknown%20as%20Schema)%0D%0A%0D%0Aresult.user.age%20%2F%2F%20should%20be%20an%20error!%0D%0Aresult.user.name%20%2F%2F%20works%20OK%0D%0Aresult.user.profile.isCool%20%2F%2F%20works%20OK%0D%0Aresult.foo%20%2F%2F%20works%20like%20expected

2
  • 1
    You really want ExpectedType['user']['profile']['isCool'] to be true | undefined and not boolean | undefined ? Commented Mar 1, 2019 at 17:06
  • @jcalz Thanks for spotting this mistake - I want boolean | undefined - fixed Commented Mar 1, 2019 at 17:10

1 Answer 1

3

So I think you want wantedSubset to conform to a type where each property is either true or itself an object of that type. We need to do a bit of footwork to get TypeScript to infer such a type where the value true is treated as type true and not boolean (at least until TS3.4 comes out and gives us const contexts):

type WantedSubset = { [k: string]: true | WantedSubset };
const asWantedSubset = <RP extends WantedSubset>(wantedSubset: RP) => wantedSubset;

const wantedSubset = asWantedSubset({
  user: {
    name: true,
    profile: {
      isCool: true
    }
  }
}); // no error, so that type works.

Now comes the type RecursivePick and friends. First of all, given a type T, you want a WantedSubset type that conforms to it. I'll call that a RecusivePicker<T>:

type RecursivePicker<T> = { 
  [K in keyof T]?: T[K] extends object | undefined ? RecursivePicker<T[K]> : true 
}

So if T is Schema, then RecursivePicker<Schema> gives you:

type RPSchema = RecursivePicker<Schema>
// type RPSchema = {
//  user?: {
//    age?: true;
//    name?: true;
//    profile?: {
//      isCool?: true;
//    }
//  }
// } 

That can be a constraint on the type of wantedSubset that you'll allow to pick from Schema types.

And here is RecursivePick in all its recursive mapped conditional horror glory:

type RecursivePick<T, KO extends RecursivePicker<T>> =
  Pick<{ [K in keyof T]: K extends keyof KO ?
    KO[K] extends true ? T[K] :
    KO[K] extends infer KOK ?
    KOK extends RecursivePicker<T[K]> ? RecursivePick<T[K], KOK> :
    never : never : never }, keyof T & keyof KO>;

It basically walks down through properties of T and examines the corresponding property of KO. If the property is true it returns the property of T unchanged. If the property is itself a bag of properties it recurses down. And if the property isn't there, it returns never. And that whole thing is Picked so that only keys appearing in both T and KO appear in the final output (this is a bit of fiddling to make sure that all the relevant mapped types are homomorphic, meaning that optional properties stay optional).

Let's verify it works:

type ExpectedType = RecursivePick<Schema, typeof wantedSubset>;

You can walk through that, but let's make the compiler verify it:

type ExpectedTypeManual = {
  user: {
    name: string;
    profile: {
      isCool?: boolean;
    };
  };
};

type MutuallyExtends<T extends U, U extends V, V=T> = true

// if no error in the next line, then ExpectedType and ExpectedTypeManual are 
//  structurally the same
type NoErrorHere = MutuallyExtends<ExpectedType, ExpectedTypeManual>

So that all works. Your function would be typed something like this:

declare function gql<Q extends RecursivePicker<S>, S>(
  query: Q | true,
  schema: S
): RecursivePick<S, Q>;

const result = gql(wantedSubset, null! as Schema); // looks good

Getting the implementation of the function to compile with no error might require an overload as conditional types are notoriously difficult to infer:

function gql<Q extends RecursivePicker<S>, S>(
  query: Q | true,
  schema: S
): RecursivePick<S, Q>;
function gql(query: any, schema: object): object {
  return {}; // something better here
}

All right, hope that helps; good luck!

Sign up to request clarification or add additional context in comments.

5 Comments

Would it be possible to detect in asWantedSubset that it's not a subset? (That some property doesn't exists in schema)
type ExactRecursivePicker<T, RP> = {[K in keyof RP]: K extends keyof T ? T[K] extends object ? ExactRecursivePicker<T[K], RP[K]> : true : never}; const asWantedSubset = <RP extends ExactRecursivePicker<Schema, RP>>(wantedSubset: RP) => wantedSubset;
That would have been caught in gql(), but if you want it caught earlier you can do something like the above.
There's just one last issue - when I make profile optional (profile?:) then the information is lost when I type result.user.profile.isCool - typescript says that it's fine, but should mention that profile is possibly undefined. Do you know how to remediate this?
Possibly you can fix it by changing RecursivePicker to type RecursivePicker<T> = { [K in keyof T]?: T[K] extends object | undefined ? RecursivePicker<T[K]> : true }, but I'm not 100% sure.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.