Search code examples
typescripttypescript-genericsrecursive-type

Referencing recursive types A <-> B in TypeScript which contain generics


I have this code in TypeScript:

type DormNodePredicate = DormEdge | (() => DormNode<DormNodePredicateRecord>);
interface DormNodePredicateRecord {
  [key: string]: DormNodePredicate;
}

export class DormNode<DNPR extends DormNodePredicateRecord> {
  constructor(public preds: DNPR) {}
}

enum PredicateType {
  STRING = "string"
}

export class DormEdge<AsArray extends boolean = false> {
  constructor(private predType: PredicateType, asArray: AsArray = false as AsArray) {}
}

const Audit = new DormNode({
  user: () => User
}) 

const User = new DormNode({
  name: new DormEdge(PredicateType.STRING),
  audits: () => Audit
});

My problem is that Audit and User are both inferred as type any. I need to be able to reference Audit from User so that I can reach a deep level of recursion such as: Audit.preds.user().preds.audits().... Of course, this is just an example, my real-world use case is much more complex and deep. How can I fix this?

As a side note, on this part of the code:

export class DormEdge<AsArray extends boolean = false> {...}

When I remove the = false from the generics, I am able to do what I intended to do. I don't exactly understand what is happening under the hood. I would appreciate if anyone could help me figure out the problem. For reference, I am using typescript@5.3.3. Thanks!


Solution

  • TypeScript's type inference algorithm is heuristic in nature, and if it goes into a loop while trying to determine a type, it will give up with an implicit any. The heuristics work well for a wide range of real world code, dealing with "common" circularities. But it can't work for everything, which would require a complete overhaul of the inference algorithm to implement full unification as discussed in microsoft/TypeScript#30134, and that is very unlikely to happen, because while it would be "correct" to do so, it would require an enormous amount of work, and probably result in a measurably worse IDE experience for a lot of common cases.

    So sometimes there will be code that the compiler can't properly analyze because it hits a circularity. To a human being it's often completely obvious how to decide the types and avoid such a circularity, but this doesn't really help with inference. There are plenty of TypeScript GitHub issues where someone reports such a circularity as a bug, but the issue is marked as a design limitation or not a defect, with comments saying "this is the best we can do without spending more resources than we can afford". For example, see microsoft/TypeScript#49837 or microsoft/TypeScript#45213, or microsoft/TypeScript#35546.

    In cases like this, the usual advice is to find a place where the compiler finds a circularity and annotate with an appropriate type. That is, you tell the compiler what the type is, and it can go on to check it. There are multiple ways to do that. In your case, it looks like there are truly recursive types involved, so you'd need to define them first:

    type Audit = DormNode<{ user: () => User }>
    type User = DormNode<{ name: DormEdge, audits: () => Audit }>;
    

    And now you can annotate at least one of Audit and User, and it works:

    const Audit: Audit = new DormNode({
      user: () => User
    })
    const User: User = new DormNode({
      name: new DormEdge(PredicateType.STRING),
      audits: () => Audit
    });
    

    Yes, this is more work for you than you'd like. Maybe there's some slightly less verbose annotation you can use, but no matter what there are going to be situations like this where the recommended approach is to cut the knot yourself instead of convincing the compiler to untie it.

    Playground link to code