Home > Enterprise >  why does typescript not allow circular references in generics?
why does typescript not allow circular references in generics?

Time:11-24

here the examples where the type directly references itself in its definition, but when abstracted via a generic it completely fails.

type a = { val: a }; // <-- doesn't care about circular references!

type record<T> = { val: T };

type b = record<b>; // <-- doesn't work!

type func<T> = (arg: T) => void;

type c = func<c>; // <-- doesn't work!

type d = (arg: d) => void; // <-- works!?

CodePudding user response:

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

TypeScript does allow circular references in generic interfaces and generic classes, since interfaces and class instances have statically known property/member/method keys and so any circularity happens in "safe" places like property values or method parameters or return type.

interface Interface<T> { val: T }
type X = Interface<X> // okay

class Class<T> { method(arg: T): void { } }
type Y = Class<Y> // okay

But for generic type aliases there is no such guarantee. Type aliases can have any structure that any anonymous type can have, so the potential circularity is not constrained to recursive tree-like objects:

type Safe<T> = { val: T };
type Unsafe<T> = T | { val: string };

When the compiler instantiates a generic type, it defers its evaluation; it does not immediately try to fully calculate the resulting type. All it sees is the form:

type WouldBeSafe = Safe<WouldBeSafe>; 
type WouldBeUnsafe = Unsafe<WouldBeUnsafe>; 

Both of those look the same to the compiler... type X = SomeGenericTypeAlias<X>. It cannot "see" that WouldBeSafe would be okay:

//type WouldBeSafe = { val: WouldBeSafe }; // would be okay

while WouldBeUnsafe would be a problem:

//type WouldBeUnsafe = WouldBeUnsafe | { val: string }; // would be error

Since it cannot see the difference, and because at least some usages would be illegally circular, it just prohibits all of them.


So, what can you do? This is one of those cases where I'd suggest using interface instead of type when you can. You can rewrite your record type (changing it to MyRecord for naming convention reasons) as an interface and everything will work:

interface MyRecord<T> { val: T };
type B = MyRecord<B>; // okay

You can even rewrite your func type (changing it to Func for naming convention reasons again) as an interface by changing the function type expression syntax into a call signature syntax:

interface Func<T> { (arg: T): void }
type C = Func<C>; // okay

Of course there are situations where you can't do that directly, such as the built-in Record utility type:

type Darn = Record<string, Darn>; // error

and you can't rewrite the mapped type Record as an interface. And indeed, it would be unsafe to try to make the keys circular, like type NoGood = Record<NoGood, string>. If you only want to do Record<string, T> for generic T, you can rewrite that as an interface:

interface Dictionary<T> extends Record<string, T> { };
type Works = Dictionary<Works>;

So there's quite often a way to use an interface instead of type to allow you to express "safe" recursive types.

Playground link to code

CodePudding user response:

Let's dissect these scenario's one by one.

Scenario 1

type a = { val: a }; // <-- doesn't care about circular references!

It's interesting this is allowed. I don't see how you could be able to construct an instance that would satisfy this type:

const A: a = {
  val: {
    val: {
      // It will always error out at the most inner node.
    }
  }
}

Scenario 2

type record<T> = { val: T };

This is not a circular reference and can be satisfied like this:

const B: record<string> = {
  val: "test"
}

Scenario 3

type b = record<b>; // <-- doesn't work!

It makes sense to me that this doesn't work. Just like in Scenario 1, there would be no way to construct an instance that satisfies this constraint.

Scenario 4

type func<T> = (arg: T) => void;

This is not a circular reference and can be satisfied like this:

const C: func<string> = (arg: string) => {}

Scenario 5

type c = func<c>; // <-- doesn't work!

It makes sense to me that this doesn't work. Just like in Scenario 1, there would be no way to construct an instance that satisfies this constraint.

Scenario 6

type d = (arg: d) => void; // <-- works!?

I can actually write a function to satisfy this constraint, but I am not sure what it's getting me:

const D: d = (arg) => {}
D(D)
  • Related