Skip to content

Overriding all parent methods breaks inferenceΒ #57035

Closed as not planned
Closed as not planned

Description

@xixixao

πŸ”Ž Search Terms

infer, extend, class, interface, methods, type parameters, generics

πŸ•— Version & Regression Information

  • This is the behavior in every version I tried, and I reviewed the FAQ for entries about inference

⏯ Playground Link

link

πŸ’» Code

// I have a class using builder pattern that I'm trying to extend
declare class Def<T> {
  a(): Def<T>;
  b<SearchField extends T>(param: SearchField): Def<T>;
}

// The extended class has to redeclare all methods, because it has a more
// concrete subtype (it has additional methods, not shown here)
declare class ExtendDef<T> extends Def<T> {
  a(): ExtendDef<T>;
  b<SubT extends T>(param: SubT): ExtendDef<T>;
}

declare const x: ExtendDef<"hello">;

// Both of these should be true, but they are not
type DoesItExtend = typeof x extends Def<any> ? true : false;
type CanItBeInferred = typeof x extends Def<infer F> ? true : false;
assert<Equals<DoesItExtend, true>>;
assert<Equals<CanItBeInferred, true>>;


// Ok, that's surprising, but this is even more surprising:
declare class ExtendDef2<T> extends Def<T> {
  a(): ExtendDef<T>;
  // no b()
}
declare const x2: ExtendDef2<"hello">;
// This works!
assert<Equals<typeof x2 extends Def<infer F> ? true : false, true>>;

// Ok, ok, but this is even even more surprising:
declare class ExtendDef3<T> extends Def<T> {
  // a(): ExtendDef<T>;
  b<SubT extends T>(param: SubT): ExtendDef<T>;
}
declare const x3: ExtendDef2<"hello">;
// This works too!
assert<Equals<typeof x3 extends Def<infer F> ? true : false, true>>;

// As long as I don't override at least one parent method,
// the inference continues working. Unfortunately I cannot do this
// in my code, I !have! to override all the parent methods.

//// START: Type-checking utils ////
type Equals<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y
  ? 1
  : 2
  ? true
  : false;

// eslint-disable-next-line @typescript-eslint/no-unused-vars
function assert<T extends true>() {
  // no need to do anything! we're just asserting at compile time that the type
  // parameter is true.
}
//// END: Type-checking utils ////

πŸ™ Actual behavior

Inference is broken when I override all methods.

πŸ™‚ Expected behavior

Inference should continue to work.

Additional information about the issue

This is blocking a library release I'm trying to make.

Also I cannot change the parent to have this in method return types, because the return types actually change (the code has been simplified from hundreds of LOCs in our project).

Activity

  1. DanielRosenwasser commented on Jan 12, 2024

    @DanielRosenwasser
    Member

    My guess is that during inference, there are no places to actually infer types from directly.

    With identical type references like Def<Blah> => Def<T>, the type system can bail out early because it knows Blah can be inferred to T.

    As the type system tries to relate an ExtendDef to a Def, it does so structurally. As it descends into the type, it finds no useful information in either a or b. It can't infer from the bounds of your generics, and where it matches up an ExtendDef to a Def the type system finds itself doing work it was already in the middle of.

    One easyish fix is to just have something like

     declare class Def<T> {
       a(): Def<T>;
       b<SearchField extends T>(param: SearchField): Def<T>;
    
    +  protected " _unused"!: T
     }

    You unfortunately cannot declare this as a private field because TypeScript makes the assumption that the types of private fields can be erased away in declaration emit, which would make inference fail again.

    Anyway, this is more of a guess. Not sure if Ryan Cavanaugh (@RyanCavanaugh) wants to take a look.


    Side note, in the code you've given, you're using a lot of type parameters that are used as inputs, but not in the return types. I would get rid of those if possible.

  2. xixixao commented on Jan 12, 2024

    @xixixao
    Author

    Thanks Daniel Rosenwasser (@DanielRosenwasser), I boiled this down from a much more complicated code. The type parameters are constrained in reality, and they are used in the type of the class, which is then used (not refuting your analysis though, I don't understand the typechecking internals well enough).

    You can find real Def here: https://github.com/get-convex/convex-js/blob/main/src/server/schema.ts#L151-L311
    And ExtendDef looks like this (with additional methods):

    interface EntDefinition<
      Document extends GenericDocument = GenericDocument,
      FieldPaths extends string = string,
      Indexes extends GenericTableIndexes = {},
      SearchIndexes extends GenericTableSearchIndexes = {},
      VectorIndexes extends GenericTableVectorIndexes = {},
      Edges extends GenericEdges = {}
    > extends TableDefinition<
        Document,
        FieldPaths,
        Indexes,
        SearchIndexes,
        VectorIndexes
      > {
    // overridden parent methods and additional methods...
    }

    One easyish fix is to just have something like

    Nice! It's good to know I don't even need to declare a method to get around this on the parent.

  3. xixixao commented on Jan 12, 2024

    @xixixao
    Author

    Hmm, so I also have to use return this: any in the parent for this to work:
    playground link

    Code:

    // I have a class using builder pattern that I'm trying to extend
    class Def<T> {
      // Comment out this field, you'll fix the error in `a` but will break
      // inference later.
      protected " _unused": T
    
      a<U>(u: U): Def<T & U> {
        // This errors, but it is possible to cast this as any to suppress the error:
        return this;
      }
      b<SearchField extends T>(param: SearchField): Def<T> {
        return this;
      }
    }
    
    // The extended class has to redeclare all methods, because it has a more
    // concrete subtype (it has additional methods, not shown here)
    declare class ExtendDef<T> extends Def<T> {
      a<U>(u: U): ExtendDef<T & U>;
      b<SubT extends T>(param: SubT): ExtendDef<T>;
    }
    
    declare const x: ExtendDef<{a: "hello"}>;
    
    // If you comment out the field in Def, the second here will error:
    type DoesItExtend = typeof x extends Def<any> ? true : false;
    type CanItBeInferred = typeof x extends Def<infer F> ? true : false;
    assert<Equals<DoesItExtend, true>>;
    assert<Equals<CanItBeInferred, true>>;

    Edit: Adding a method without adding fields seems to be a better workaround for now:

    class Def<T> {
      a<U>(u: U): Def<T & U> {
        // This errors, but it is possible to cast this as any to suppress the error:
        return this;
      }
      b<SearchField extends T>(param: SearchField): Def<T> {
        return this;
      }
    
      // This methods which ExtendDef won't override fixes the issue
      self(): Def<T> {
        return this;
      }
    }
  4. RyanCavanaugh commented on Jan 12, 2024

    @RyanCavanaugh
    Member

    I'd need a concrete example where inference was failing the presence of what could have been a deducible structural inference to classify this as a bug

  5. xixixao commented on Jan 12, 2024

    @xixixao
    Author

    Ryan Cavanaugh (@RyanCavanaugh) Does this example help? playground link

    // I have a class using builder pattern that I'm trying to extend
    class Def<T> {
      setT<T>(t: T): Def<T> {
        return this;
      }
      a<U>(u: U): Def<T & U> {
        // It is possible to cast this as any to suppress the error:
        return this;
      }
      b<SearchField extends T>(param: SearchField): Def<T> {
        return this;
      }
      // Uncomment for the inference to work again
      // m(): Def<T> {
      //   return this;
      // }
    }
    
    // The extended class has to redeclare all methods, because it has a more
    // concrete subtype (it has additional methods, not shown here)
    declare class ExtendDef<T> extends Def<T> {
      setT<T>(t: T): ExtendDef<T>;
      a<U>(u: U): ExtendDef<T & U>;
      b<SubT extends T>(param: SubT): ExtendDef<T>;
    }
    
    const x = (new ExtendDef()).setT({x: "hello"}).a({y: "world"});
    
    type DoesItExtend = typeof x extends Def<any> ? true : false;
    type Inferred = typeof x extends Def<infer F> ? F : never;
    assert<Equals<DoesItExtend, true>>;
    // This errors, if you uncomment the m() method this will pass
    assert<Equals<Inferred, {x: string} & {y: string}>>;
  6. RyanCavanaugh commented on Jan 12, 2024

    @RyanCavanaugh
    Member

    All uses of T in Def are self-referential or in constraint positions, so no

  7. xixixao commented on Jan 12, 2024

    @xixixao
    Author

    What would make T not be those? How come uncommenting m() changes things? (sorry I'm not that familiar with the type-checking details)

  8. typescript-bot commented on Jan 15, 2024

    @typescript-bot
    Contributor

    This issue has been marked as "Not a Defect" and has seen no recent activity. It has been automatically closed for house-keeping purposes.

  9. locked as resolved and limited conversation to collaborators on Oct 22, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Not a DefectThis behavior is one of several equally-correct options

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions