Infer generic type parameters in TypeScript over multiple levels

Viewed 1447

I have the following common scenario in my TypeScript code:

interface TopLevelInterface<A extends BottomLevelInterface<B>, B>

and an implementation for BottomLevelInterface that looks like this:

class BottomLevelClass implements BottomLevelInterface<MyType>

My question is: When implementing TopLevelInterface I not only need to pass the type argument for A which would be BottomLevelClass, but also the second type argument for B which would be MyType in the above example.

Why do I need to specify B which could easily be inferred by looking at the type argument of BottomLevelClass ?

For example, when implementing TopLevelInterface I need to specify the following:

class TopLevelClass implements TopLevelInterface<ConcreteBottomLevel, MyType>

Instead of the shorter version that should be sufficient:

class TopLevelClass implements TopLevelInterface<ConcreteBottomLevel>

Why is this necessary? How can I infer the second type argument by looking at the first one? The only solution I came up with is using infer in TypeScript 2.8+ with a default assignment. This solution looks like this:

interface TopLevelInterface<A extends BottomLevelInterface<B>, 
    B = A extends BottomLevelInterface<infer _B> ? _B : any>

However, I'm not able to correctly apply this with a three-layer class hierarchy. In the following StackBlitz you can see that I cannot get the correct type constraint for the property model of TopLevelClass. It should be inferred to the type SomeType but instead is inferred to never. At MiddleLevelClass it does work correctly.

https://stackblitz.com/edit/typescript-pcxnzo?file=infer-generics.ts

Can anyone explain the issue or a better way to achieve the desired result?

1 Answers

The deliberate error in MiddleLevelClass is affecting the behavior of TopLevelClass, so for a valid test, we should base TopLevelClass on a correct MiddleLevelClass and use a separate BadMiddleLevelClass to demonstrate the error there.

Your first problem is that your conditional types have an "else" case of any, which will tend to hide errors. never tends to be better and is commonly used, though a full solution would require a unique invalid type.

With these changes, it seems the main problem is that when you write TopLevelInterface<MiddleLevelClass> and TypeScript tries to evaluate B = M extends MiddleLevelInterface<infer _B> ? _B : never, there are no inferences for _B because the B parameter is not actually used by MiddleLevelInterface or MiddleLevelClass. See this FAQ. Adding a dummy optional property that uses B fixes the problem. (I guess you used B for something in your real application, otherwise you wouldn't have declared B, but you removed the use in the simplified example?)

New code:

export class SomeType {
  x: string;
}

export interface BottomLevelInterface<T> {
  model : T;
}

export class BottomLevelClass implements BottomLevelInterface<SomeType> {
  model: SomeType;
}

export interface MiddleLevelInterface<B extends BottomLevelInterface<T>, 
    T = B extends BottomLevelInterface<infer _T> ? _T : never> {
  _dummy_B?: B;

  model: T;
} 

export class MiddleLevelClass implements MiddleLevelInterface<BottomLevelClass> {
  _dummy_B?: BottomLevelClass;
  model: SomeType;
}

export class BadMiddleLevelClass implements MiddleLevelInterface<BottomLevelClass> {
  // here we correctly see an error from TypeScript service, as 'string' cannot be applied to 'SomeType'
  model: string;
}

export interface TopLevelInterface <M extends MiddleLevelInterface<B, T>,
    B extends BottomLevelInterface<T> = M extends MiddleLevelInterface<infer _B> ? _B : never,
    T = B extends BottomLevelInterface<infer _T> ? _T : never> {

  model: T;
}

export class TopLevelClass implements TopLevelInterface<MiddleLevelClass> {
  // now there is an error here
  model: string;
}

Alternative solution to the original problem based on jcalz's suggestion (thanks!): Instead of using multiple type parameters, use helper type aliases to determine the T type from the B type and the B type from the M type every time you need them. Here's the code:

export class SomeType {
  x: string;
}

export interface BottomLevelInterface<T> {
  model : T;
}

export class BottomLevelClass implements BottomLevelInterface<SomeType> {
  model: SomeType;
}

type TfromB<B extends BottomLevelInterface<any>> = B extends BottomLevelInterface<infer T> ? T : never;

export interface MiddleLevelInterface<B extends BottomLevelInterface<any>> {
  _dummy_B?: B;

  model: TfromB<B>;
} 

export class MiddleLevelClass implements MiddleLevelInterface<BottomLevelClass> {
  _dummy_B?: BottomLevelClass;
  model: SomeType;
}

export class BadMiddleLevelClass implements MiddleLevelInterface<BottomLevelClass> {
  // here we correctly see an error from TypeScript service, as 'string' cannot be applied to 'SomeType'
  model: string;
}

type BfromM<M extends MiddleLevelInterface<any>> = M extends MiddleLevelInterface<infer B> ? B : never;

export interface TopLevelInterface <M extends MiddleLevelInterface<any>> {

  model: TfromB<BfromM<M>>;
}

export class TopLevelClass implements TopLevelInterface<MiddleLevelClass> {
  // now there is an error here
  model: string;
}

I wouldn't be surprised though if the reliance on conditional types instead of explicit type parameters makes certain operations that go beyond this simple example more difficult. You can try it and see.

Related