Search code examples
typescriptnestedrecord

Recursive type for nested record in TypeScript


Since version 3.7, TypeScript has support for recursive types, which I thought would help me create a proper type for a nested record structure, but so far I haven't managed to make the compiler happy with my attempts.

Let's forget about generics and say as a minimal example, that I have an object, which has numeric values at arbitrary depths behind string keys.

type NestedRecord = Record<string, number | NestedRecord>

const test: NestedRecord = {
  a: 10,
  b: {
    c: 20,
    d: 30,
  }
}

This typing would make sense to me, but the compiler gives me the following error:
Type alias 'NestedRecord' circularly references itself.

Is there some limitation of the support for recursive types that I am not aware of? If so, how could a type for this structure be achieved?


Edit: Based on the answer of @jcalz I ended up using the following generic definition:

type NestedRecord<T> = { [key: string]: NestedRecordField<T> }
type NestedRecordField<T> = T | NestedRecord<T>

In my case separating these made sense, as a recursive function operating on such records needed the NestedRecordField type anyway, but the following one line solution is also valid:

type NestedRecord<T> = { [key: string]: T | NestedRecord<T> }

Solution

  • See microsoft/TypeScript#41164 for a canonical answer to this question.

    TypeScript allows some circular definitions, but some are prohibited. You can't have type Oops = Oops, for example. If you have a generic type like type Foo<T> = ..., then it's not clear whether type Bar = Foo<Bar> will be allowed or prohibited unless the compiler is willing to eagerly substitute Foo with its definition. And the compiler doesn't do this; it defers such evaluation (lest compiler performance suffer dramatically). So the compiler will reject type Bar = Foo<Bar> even if it turns out that the definition of Foo is harmless. It's a design limitation of TypeScript.

    So even though the Record<K, V> utility type is harmless for recursive types in its V parameter, the compiler fails to notice that, and you get an error.


    The recommend approach here is to replace Record<K, V> with its definition {[P in K]: V}. For Record<string, V> that would look like {[P in string]: V}, which evaluates to a type with the string index signature {[k: string]: V}. So you can use that directly:

    type NestedRecord =
        { [k: string]: number | NestedRecord }; // okay
    

    And then your assignment works as expected:

    const test: NestedRecord = {
        a: 10,
        b: {
            c: 20,
            d: 30,
        }
    }
    

    Playground link to code