3

NOTE: See edit below for final solution based on @GarlefWegart's response.

I'm trying to write generic typings for dynamic GraphQL query results (more for fun, since I'm sure these probably exist somewhere already).

I'm very close, but have tapped out on a weird problem. The full code is here in a playground, and reproduced below.

The problem is centering around indexing an object using the keys of a derived object, which should work but for some reason is failing. Notice in the Result definition that I can't index T using K, even though K is defined as a key of U, and U is defined as a subset of the properties of T. This means that all of the keys of U are necessarily also keys of T, so it should be safe to index T with any key of U. However, Typescript refuses to do so.

type SimpleValue = null | string | number | boolean;
type SimpleObject =  { [k: string]: SimpleValue | SimpleObject | Array<SimpleValue> | Array<SimpleObject> };

type Projection<T extends SimpleObject> = {
    [K in keyof T]?:
        T[K] extends SimpleObject
            ? Projection<T[K]>
            : T[K] extends Array<infer A>
                ? A extends SimpleObject
                    ? Projection<A>
                    : boolean
                : boolean;
};

type Result<T extends SimpleObject, U extends Projection<T>> = {
    [K in keyof U]:
        U[K] extends false
            ? never                            // don't return values for false keys
            : U[K] extends true
                ? T[K]                         // return the original type for true keys
               // ^^vv All references to T[K] throw errors
                : T[K] extends Array<infer A>
                    ? Array<Result<A, U[K]>>   // Return an array of projection results when the original was an array
                    : Result<T[K], U[K]>;      // Else it's an object, so return the projection result for it
}


type User = {
  id: string;
  email: string;
  approved: string;
  address: {
    street1: string;
    city: string;
    state: string;
    country: {
      code: string;
      allowed: boolean;
    }
  };
  docs: Array<{
    id: string;
    url: string;
    approved: boolean;
  }>
}

const projection: Projection<User> = {
  id: false,
  email: true,
  address: {
    country: {
      code: true
    }
  },
  docs: {
    id: true,
    url: true
  }
}

const result: Result<User, typeof projection> = {
    email: "[email protected]",
    address: {
        country: {
            code: "US"
        }
    },
    docs: [
        {
            id: "1",
            url: "https://abcde.com/docs/1"
        },
        {
            id: "2",
            url: "https://abcde.com/docs/2"
        }
    ]
}

Any insight is appreciated.

Edit Mar 10, 2021

I was able to get to an acceptable solution based on Garlef Wegart's response below. See the code here.

Note, however, that it's very finicky. It fits my use-case fine because I'm typing a GraphQL API, so the response comes in as unknown over the wire and is then cast to the value inferred by the input parameters, but these types may not work in other situations. The important part for me was not the assignment part, but the consumption of the resulting type, and this solution does work for that. Best of luck to anyone else trying for this!

Second Note: I've also published these types as a small Typescript package on github (here). To use it, just add @kael-shipman:repository=https://npm.pkg.github.com/kael-shipman to your npmrc somewhere and then install the package as normal.

2
  • Should it not be Array<Projection<A>> in the definition of Projection? Commented Feb 24, 2021 at 21:39
  • 1
    @GarlefWegart this is a weird quirk in GraphQL syntax. GraphQL ignores arrays, focusing instead purely on the shapes of the objects. I wrote it this way so that you could write projections that look just like GrpahQL syntax. Commented Feb 26, 2021 at 3:13

1 Answer 1

3

It is true and obvious from the definition of Projection that keyof Projection<T> is a subset of keyof T -- for a given T.

BUT: U extends(!) Projection<T> so U itself very much can have keys not present in T. And the values stored under these keys can be basically anything.

So: Mapping over keyof U is not the correct thing to do. Instead, you could map over keyof T & keyof U.

You also should restrict U further to only have properties you want by adding U extends ... & Record<string, DesiredConstraints>. Otherwise you can pass in objects with bad properties. (I guess in your case it should be ... & SimpleObject?)

Here's a simplified example (without the complexity of your domain) illustrating some of the intricacies (Playground link):

type Subobject<T> = {
  [k in keyof T as T[k] extends "pass" ? k : never]:
    T[k]
}

type SomeGeneric<T, U extends Subobject<T>> = {
  [k in keyof U]:
    k extends keyof T
      ? "yep"
      : "nope"
}

type Sub = Subobject<{ a: 1, b: "pass" }>

// This is not what we want: `c: "nope"` shoud not be part of our result!
type NotWanted = SomeGeneric<{ a: 1, b: "pass" }, { b: "pass", c: "other" }>


type Safe<T, U extends Subobject<T>> = {
  [k in (keyof U & keyof T)]:
    k extends keyof T
      ? "yep"
      : "nope"
}

type Yeah = Safe<{ a: 1, b: "pass" }, { b: "pass", c: "other" }>

// Next problem: U can have bad properties!
function doStuffWithU<T, U extends Subobject<T>>(u: U) {
  for (const val of Object.values(u)) {
    if (val === "other") {
      throw Error('Our code breaks if it receives "other"')
    }
  }
}

const u = { b: "pass", c: "other" } as const
// This will break but the compiler does not complain!
const error = doStuffWithU<Sub, typeof u>(u)


// But this can be prevented
type SafeAndOnlyAllowedProperties<T, U extends Subobject<T> & Record<string, "pass">> = {
  [k in (keyof U & keyof T)]:
    // No complaints here due to `& Record<string, "pass">`
    OnlyAcceptsPass<U[k]>
}

type OnlyAcceptsPass<V extends "pass"> = "pass!"

// The type checker will now complain `"other" is not assignable to type "pass"`
type HellYeah = SafeAndOnlyAllowedProperties<{ a: 1, b: "pass" }, { b: "pass", c: "other" }>

EDIT: After a second thought: When defining a function instead of a generic type, you could also go with the following pattern to prevent bad input

const safeFn = <U extends Allowed>(u: U & Constraint) => {
  // ...
}
Sign up to request clarification or add additional context in comments.

2 Comments

Aaaah, the "U extends Projection<T> and so can have additional properties" is a good point that I hadn't thought of :). Do you know, is there any way to alias Projection<T> in this case without extending it? My understanding of the "default value" syntax (i.e., U = Projection<T>) is just that - that it provides a default value, but that the end user can assign a different value.
Ok, I was able to get what I wanted with help from your answer, so I'm accepting it. My final solution is linked in an edit to my original question above, although it should be noted that it's quite finicky. (I couldn't include the link in a comment because it was too long.) Thanks for the push in the right direction!

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.