0

Say I have an object:

{
  ItemA: {},
  ItemB: {
    SubitemA: {
      SubitemB: {}
    }
  },
  ItemC: {}
}

I would like to define a recursive type in TypeScript for the objects with the same structure using generics. I don't know how to do it correctly, I tried this:

type Items<T> = {
  [K in keyof T]: Items<T[K]>|{};
};

Then in my code I would like to have type checking:

function doSomething<T>(items: Items<T>){
  for (const [k, v] of Object.entries(items)){
     // For Typescript v is undefined here
     if(Object.keys(items).length > 0){
        doSomething(v)
     }
  }
}

jonrsharpe
  • 115,751
  • 26
  • 228
  • 437
flame79
  • 67
  • 4

1 Answers1

2

The main issue you're running into here is that Object.entries(items)'s typing does not generally return a strongly-typed array of entries corresponding to the known properties of items. Instead, returns Array<[string, unknown]> (that is unknown, not undefined as your comment mentions).

This is because TypeScript's object types aren't exact. A value of the type {foo: string} must have a string-valued foo property, but it might have any other properties of any other type. The canonical SO question/answer about this sort of issue is Why doesn't Object.keys return a keyof type in TypeScript?, where Object.keys(obj) returns string[] instead of Array<keyof typeof obj>.

So, technically speaking, the compiler is correct to say it doesn't know what v is there. I don't know what you're really going to do inside of doSomething(), but it is technically possible for the recursive call to hit non-Items properties where Object.entries() gives an error. For example:

interface Foo {
     a: {}
}
interface Bar extends Foo {
     b: null;
}
const bar: Bar = {a: {}, b: null};
const foo: Foo = bar;
doSomething(foo); // ERROR AT RUNTIME! Can't convert null to object

The compiler accepts doSomething(foo) because foo is of type Foo, which matches Items<{a: unknown}>. But it turns out foo is also of type Bar (which extends Foo and is therefore a valid Foo). And when you call doSomething(foo) you will hit a runtime error, because Object.entries(foo) returns at least one entry you didn't expect.


If you are not worried about such edge cases, you could use a type assertion to give a stronger type to the return value of Object.entries():

type Entry<T> = { [K in keyof T]-?: [K, T[K]] }[keyof T]

function doSomething<T>(items: Items<T> | {}) {
     for (const [k, v] of Object.entries(items) as Array<Entry<Items<T>>>) {
          console.log(k, v);
          doSomething(v); // okay now
     }
}

The type Array<Entry<Items<T>>> is probably what you imagine Object.entries(items) to return assuming that items's type were exact/closed... Array<Entries<Foo>> is, for example, Array<["a", {}]>. This will cause the compiler to infer that v is of type Items<T>[keyof T] which is going to be some kind of object.

Again, this doesn't prevent the edge cases like doSomething(foo) mentioned above; it just assumes they won't happen.


Also, if you didn't notice, I needed to widen the type of items from Items<T> to Items<T> | {}, so that recursive calls can be seen as safe by the compiler. Hopefully that is acceptable to you.


Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360