javascriptnode.jstypescriptmongodbtypescript-typings

Typescript object keys type with recursive subobject adresses, like mongoDb filter


Let's say I have a model:

type MyModel = {
  string: string
  subObject: {
    subObjString: string
  }
}

I would like to have a type so all those tests passes:

  • { string: 'foo' }
  • { subObject: { subObjString: 'foo'} }
  • { 'subObject.subObjString': 'foo' }
  • { 'subObject.notExistingprop': 'foo' }
  • { notExisting: 'foo' }

My goal is to type a filter input for a mongoDb ORM where filters can be written with classic and dotted syntax.

My first attempt was to get the keys type right, so here is what I have done so far:

Typescript Playground Link

type FilterType<O extends Record<string, any>> = {
    [K in (keyof O & string)]: O[K] extends Record<string, any> ? K extends symbol ? never : `${K}.${FilterType<O>}` : never
}[string]


type MyModel = {
  string: string
  subObject: {
    subObjString: string
  }
}

type FilterTypeForModel = FilterType<MyModel> // interpreted as unknown

const test1 = ['string', 'subObject'] satisfies FilterTypeForModel // should work
const test2 = ['subObject.subObjString'] satisfies FilterTypeForModel // should work
const test3 = ['subObject.notExistingProp'] satisfies FilterTypeForModel // Should not work

Solution

  • Here is how to get all possible paths from an object as a union type:

    type DottedKeys<T> = {
      [K in keyof T]: 
      K extends string 
      ? T[K] extends object 
      ? K | `${K}.${DottedKeys<T[K]>}`
      : K
      : 'none'
    }[keyof T]
    
    type MyModel = {
      string: string;
      subObject: {
        subObjString: string;
        subObj2: {
          string3lv: string
          number3lv: number
        }
      };
    };
    
    // ObjectAllPaths contains all possible paths
    type ObjectAllPaths = DottedKeys<MyModel> // "string" | "subObject" | "subObject.subObjString" | "subObject.subObj2" | "subObject.subObj2.string3lv" | "subObject.subObj2.number3lv"
    
    // Paths Test cases
    type Test1 = 'string' extends ObjectAllPaths ? true : false; // Should be true
    type Test2 = 'subObject' extends ObjectAllPaths ? true : false; // Should be true
    type Test3 = 'subObject.subObjString' extends ObjectAllPaths ? true : false; // Should be true
    type Test4 = 'subObject.notExistingProp' extends ObjectAllPaths ? true : false; // Should be false
    type Test5 = 'notExisting' extends ObjectAllPaths ? true : false; // Should be false
    

    To go further, as you said that you need that type to be an object finally, here is my attempt to provide an object version:

    type OnlyDotted<T> = {
      [K in keyof T]: 
      K extends string 
      ? T[K] extends object 
      ? `${K}.${DottedKeys<T[K]>}`
      : never
      : never
    }[keyof T]
    
    type AsFilter<T extends Record<string, any>> = Partial<{
      [K in keyof T]: T[K] extends Record<string, any> ? AsFilter<T[K]> : T[K]
    } & { [key in OnlyDotted<T>]: any }>
    
    type FilterModel = AsFilter<MyModel>
    
    const modelFilter: FilterModel  = {
        "subObject.subObjString": true, // ✅ dotted path ❌ failed type validation here, need to be improved
        subObject: {
            "subObj2.string3lv": true, // ✅ recursive dotted path ❌ failed type validation here, need to be improved
            subObj2: {
              string3lv: 'string', // ✅ normal path
              number3lv: true //  ✅ should error, type validation working
            }
        }
    }
    

    NOTE: this is not perfect since as you see in comments all dotted paths are typed as any. I couldn't find a better solution for now

    Here is an updated typescript playground