TypeScript: Circular reference error when defining Record type

TypeScript Version: 4.0.2 (and 4.1.0-dev.20201015)

Search Terms: circularly, Record

Code

type T1 = { [key: string]: T1 }
type T2 = Record<string, T2>

Expected behavior: No compilation errors

Actual behavior: While type T1 compiles properly, type T2 reports Type alias 'T2' circularly references itself. even though they are inherently the same type. This was actually found by autofixing a new @typescript-eslint rule consistent-indexed-object-style

Playground Link: https://www.typescriptlang.org/play?ts=4.0.2#code/C4TwDgpgBAKgjFAvFA3lA2gawiAXFAZ2ACcBLAOwHMBdfeKAXwFgAoUSWAJiSgCUIAxgHtiAEwA8RMlQA0XAHxA

Related Issues: None found

About this issue

  • Original URL
  • State: closed
  • Created 4 years ago
  • Reactions: 22
  • Comments: 19 (2 by maintainers)

Most upvoted comments

@RyanCavanaugh not sure what’s under the hood of Record, but it does feel a bit inconsistent that some generics seem to work fine with recursions while others don’t:

type A<T> = T | Array<A<T>>  // This line works fine
type R = Record<string, R>      // this one doesn't

Certain circularities are allowed (e.g. T1) but other circularities aren’t, e.g.

type Identity<T> = T;
type T3 = Identity<T3>;

Generic instantiation is deferred, so at the point TS analyzes T2’s declaration, it can’t tell if it’s in the Record case (would be allowed) or the Identity case (would not be allowed). It’s only later in the process that we could tell that this is actually OK, but if it wasn’t OK, it’s “too late” to go back and start complaining about it.

@jedwards1211 good point, also what you’ve described is a workaround for this very issue!

type ThisFails = number | Record<string, ThisFails>

type ThisWorks = number | {[key: string]: ThisWorks}

playground

The thing that’s frustrating about this is if we define

type MyRecord<V> = {[key: string]: V}

Because of the = the natural expectation is that both sides are truly equivalent and interchangeable. But this equivalence gets violated when we take

type T = {[key: string]: T} // okay

And then try to substitute the RHS with what should be equivalent:

type T = MyRecord<T> // error, even though this means the same thing

So MyRecord<V> isn’t actually equivalent to {[key: string]: V} in all cases. Are there other cases I don’t know about where the sides of a type alias aren’t equivalent?

Why is it so painful to write recursive programs with TypeScript?

type texpr =
  | number
  | string
  | Array<texpr>
  | Promise<texpr>
  | Record<string, texpr> // circular reference error 😓
async function evaluate(t: texpr): Promise<texpr> {
  switch (t.constructor) {
    case Number: ...
    case String: ...
    case Array: ...
    case Object: ...
    case Promise: ...
  }
}

What’s the fix without a bunch of goofy business?

This seems work:

// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface ThisWorks extends Record<string, ThisWorks> {}

{ [key: string]: T } may not be very useful since { 'foo': 'bar' } is a Record<string, string> but not a { [key: string]: T }

BTW, the issue I mentioned with typescript-eslint was fixed (https://github.com/typescript-eslint/typescript-eslint/issues/2687)

@RyanCavanaugh The behaviour seems a bit inconsistent to me (or at least not very transparent)

In the following example a code change somewhere deep down resulted in a failure.

Playground link

namespace Works {
    type Value = Sum<{
        PRODUCT: Product<Value>
    }>

    // Defined in another file

    type Product<T = any> = Record<string, T>;

    type ValueOf<X> = X[keyof X];
    type Sum<Cases extends Product> = ValueOf<SumMapping<Cases>>;
    type SumMapping<Cases extends Product> = {
        [cas in keyof Cases]:
            // ------ difference is here ---------
            {
                type: cas;
                value: Cases[cas];
            };
    };

    
}

namespace Fails {
    type Value = Sum<{
        PRODUCT: Product<Value>
    }>

    // Defined in another file

    type Product<T = any> = Record<string, T>;

    type ValueOf<X> = X[keyof X];
    type Sum<Cases extends Product> = ValueOf<SumMapping<Cases>>;
    type SumMapping<Cases extends Product> = {
        [cas in keyof Cases]:
            // ------ difference is here ---------
            [type: cas, value: Cases[cas]]
    };
}

@RyanCavanaugh I mean obviously that seems undecidable, but I still don’t see what it has to do with OP’s example of type T2 = Record<string, T2>? Whether something like {foo: {}} is of type T2 seems perfectly decidable (not sure about deciding subtype/supertype relationships but that must not be a problem when T2 is defined as T2 = {[key: string]: T2}, so alias or not, the actual structure we’re talking about is well-defined)

Imagine I told you that an integer was a flarpnumber if it was either prime or one of its digits was a flarpnumber. Is 6 a flarpnumber?