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)
     }
  }
}

1
  • What does the generic represent here? And how do the keys change for every level? Commented Nov 30, 2020 at 17:31

1 Answer 1

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

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.