Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Typescript Cannot index type T using key defined as subset of keys of type T

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.

like image 501
kael Avatar asked Nov 23 '25 11:11

kael


1 Answers

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) => {
  // ...
}
like image 69
Gerrit Begher Avatar answered Nov 26 '25 05:11

Gerrit Begher



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!