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