5

I am working with Typescript and firebase and I have a small abstraction layer with this function to search for a unique document base on its field name and its value.

  where<K extends keyof (T & DocumentEntity)>(fieldName: K, operator: WhereFilterOp, value: unknown): Query<T> {
    this.addCriterion(new WhereCriterion(fieldName as string, operator, value));
    return this;
  }

This works well when I want to query with a field at the base of the document, for example:

Model:

order: Order = {
  orderId: baseId
  item: { ... }
  price: { ... }
  restaurant: {
    restaurantId: nestedId
    name: chezGaston
  }
}

Query:

    const order = await this.documentPersistence.findUnique(
      new Query<order>().where('orderId', '==', incomingOrderId)
    );

But now I want to query base on the id of a nested object.

const order = await this.documentPersistence.findUnique(
      new Query<order>()
        .where('restaurant.restaurantId', '==', integration),
    );

And this gives me a static error TS2345: Argument of type '"restaurant.restaurantId"' is not assignable to parameter of type 'keyof Order'.

How can I fix my function so it accepts Nested object as keyof my object?

I don't want to use // @ts-ignore

2 Answers2

19

You can do this as of TypeScript 4.1.

Click the playground example to see it in action:

TypeScript Playground

Original Twitter Post

Here's the relevant code:

type PathImpl<T, K extends keyof T> =
  K extends string
  ? T[K] extends Record<string, any>
    ? T[K] extends ArrayLike<any>
      ? K | `${K}.${PathImpl<T[K], Exclude<keyof T[K], keyof any[]>>}`
      : K | `${K}.${PathImpl<T[K], keyof T[K]>}`
    : K
  : never;

type Path<T> = PathImpl<T, keyof T> | keyof T;

type PathValue<T, P extends Path<T>> =
  P extends `${infer K}.${infer Rest}`
  ? K extends keyof T
    ? Rest extends Path<T[K]>
      ? PathValue<T[K], Rest>
      : never
    : never
  : P extends keyof T
    ? T[P]
    : never;

declare function get<T, P extends Path<T>>(obj: T, path: P): PathValue<T, P>;

const object = {
  firstName: "Diego",
  lastName: "Haz",
  age: 30,
  projects: [
    { name: "Reakit", contributors: 68 },
    { name: "Constate", contributors: 12 },
  ]
} as const;

get(object, "firstName"); // works
get(object, "projects.0"); // works
get(object, "projects.0.name"); // works

get(object, "role"); // type error

jeremiahmontoya
  • 216
  • 3
  • 4
  • I'm a TS beginner, what those this line do ```K | `${K}.${PathImpl>}``` – François Duguay-Giguère Jul 17 '21 at 14:34
  • This line is used to limit the available nested keys to non-Array prototype properties (e.g, `map`, `reduce`). See this playground with that rule omitted: https://shorturl.at/huBH1 and see how `map` is incorrectly allowed in the key now. – jeremiahmontoya Jul 17 '21 at 21:17
  • 1
    Very nice, however, the array access is broken in 4.5.2. – Robula Dec 10 '21 at 11:36
  • 3
    Tried using this in an Angular service and the typescript fails to compile with `error TS2589: Type instantiation is excessively deep and possibly infinite.` – Adam Apr 11 '22 at 17:50
  • running in the playground fails with "[ERR]: get is not defined". which may be expected, but I got disappointed because you advertised "...to see it in action" :) – Cee McSharpface Feb 03 '23 at 10:19
0

If you want to get only relevant keys (exlcuding the root ones), for example if you have a translation object, the you can use the following type:

type FlattenKeys<T> = T extends object
   ? {
        [K in keyof T]: T[K] extends infer U
           ? `${Extract<K, string>}${FlattenKeys<U> extends '' ? '' : '.'}${FlattenKeys<U>}`
           : never
     }[keyof T]
   : ''
kuubson
  • 155
  • 1
  • 3