typescriptgenericstypesvisitor-patternspecification-pattern

Type does not satisfy the constraint and incorrectly extends interface when trying to implement generic Specification and Visitor patterns


I'm trying to implement a generic Specification pattern and a generic Visitor pattern together. Here are my base interfaces.

export interface Specification<T, TVisitor extends SpecificationVisitor<TVisitor, T>> {
  accept(visitor: TVisitor): void;
  isSatisfiedBy(candidate: T): boolean;
  and(other: Specification<T, TVisitor>): Specification<T, TVisitor>;
  andNot(other: Specification<T, TVisitor>): Specification<T, TVisitor>;
  or(other: Specification<T, TVisitor>): Specification<T, TVisitor>;
  orNot(other: Specification<T, TVisitor>): Specification<T, TVisitor>;
  not(): Specification<T, TVisitor>;
}

export interface SpecificationVisitor<TVisitor extends SpecificationVisitor<TVisitor, T>, T> {
  visit(specification: AndSpecification<T, TVisitor>): void;
  visit(specification: AndNotSpecification<T, TVisitor>): void;
  visit(specification: OrSpecification<T, TVisitor>): void;
  visit(specification: OrNotSpecification<T, TVisitor>): void;
  visit(specification: NotSpecification<T, TVisitor>): void;
}

For convenience, I've implemented some base classes and an abstract class for the basic boolean operators.

export abstract class CompositeSpecification<T, TVisitor extends SpecificationVisitor<TVisitor, T>> implements Specification<T, TVisitor> {
  abstract isSatisfiedBy(candidate: T): boolean;
  abstract accept(visitor: TVisitor): void;

  and(other: Specification<T, TVisitor>): Specification<T, TVisitor> {
    return new AndSpecification<T, TVisitor>(this, other);
  }
  andNot(other: Specification<T, TVisitor>): Specification<T, TVisitor> {
    return new AndNotSpecification<T, TVisitor>(this, other);
  }
  or(other: Specification<T, TVisitor>): Specification<T, TVisitor> {
    return new OrSpecification<T, TVisitor>(this, other);
  }
  orNot(other: Specification<T, TVisitor>): Specification<T, TVisitor> {
    return new OrNotSpecification<T, TVisitor>(this, other);
  }
  not(): Specification<T, TVisitor> {
    return new NotSpecification<T, TVisitor>(this);
  }
}

export class AndSpecification<T, TVisitor extends SpecificationVisitor<TVisitor, T>> extends CompositeSpecification<
  T,
  TVisitor
> {
  constructor(readonly left: Specification<T, TVisitor>, readonly right: Specification<T, TVisitor>) {
    super();
  }

  accept(visitor: TVisitor): void {
    visitor.visit(this);
  }

  isSatisfiedBy(candidate: T): boolean {
    return this.left.isSatisfiedBy(candidate) && this.right.isSatisfiedBy(candidate);
  }
}

export class AndNotSpecification<T, TVisitor extends SpecificationVisitor<TVisitor, T>> extends CompositeSpecification<T, TVisitor> {
  constructor(readonly left: Specification<T, TVisitor>, readonly right: Specification<T, TVisitor>) {
    super();
  }

  accept(visitor: TVisitor): void {
    visitor.visit(this);
  }

  isSatisfiedBy(candidate: T): boolean {
    return this.left.isSatisfiedBy(candidate) && !this.right.isSatisfiedBy(candidate);
  }
}

export class OrSpecification<T, TVisitor extends SpecificationVisitor<TVisitor, T>> extends CompositeSpecification<
  T,
  TVisitor
> {
  constructor(readonly left: Specification<T, TVisitor>, readonly right: Specification<T, TVisitor>) {
    super();
  }

  accept(visitor: TVisitor): void {
    visitor.visit(this);
  }

  isSatisfiedBy(candidate: T): boolean {
    return this.left.isSatisfiedBy(candidate) || this.right.isSatisfiedBy(candidate);
  }
}

export class OrNotSpecification<T, TVisitor extends SpecificationVisitor<TVisitor, T>> extends CompositeSpecification<
  T,
  TVisitor
> {
  constructor(readonly left: Specification<T, TVisitor>, readonly right: Specification<T, TVisitor>) {
    super();
  }

  accept(visitor: TVisitor): void {
    visitor.visit(this);
  }

  isSatisfiedBy(candidate: T): boolean {
    return this.left.isSatisfiedBy(candidate) || !this.right.isSatisfiedBy(candidate);
  }
}

export class NotSpecification<T, TVisitor extends SpecificationVisitor<TVisitor, T>> extends CompositeSpecification<
  T,
  TVisitor
> {
  constructor(readonly other: Specification<T, TVisitor>) {
    super();
  }

  accept(visitor: TVisitor): void {
    visitor.visit(this);
  }

  isSatisfiedBy(candidate: T): boolean {
    return !this.other.isSatisfiedBy(candidate);
  }
}

All of the above works and compiles without error. But when I run into problems with the compiler when I try to create an interface that extends the base SpecificationVisitor interface and implement a class that extends the abstract CompositeSpecification.

export interface NumberComparatorVisitor extends SpecificationVisitor<NumberComparatorVisitor, number> {
  visit(specification: GreaterThan): void;
}

export class GreaterThan extends CompositeSpecification<number, NumberComparatorVisitor> {
  constructor(readonly value: number) {
    super();
  }

  accept(visitor: NumberComparatorVisitor): void {
    visitor.visit(this);
  }

  isSatisfiedBy(candidate: number): boolean {
    return candidate > this.value;
  }
}

I get the following errors:

Type 'NumberComparatorVisitor' does not satisfy the constraint 'SpecificationVisitor<NumberComparatorVisitor, number>'.ts(2344)

Interface 'NumberComparatorVisitor' incorrectly extends interface 'SpecificationVisitor<NumberComparatorVisitor, number>'.
  Types of property 'visit' are incompatible.
    Type '(specification: GreaterThan) => void' is not assignable to type '{ (specification: AndSpecification<number, NumberComparatorVisitor>): void; (specification: AndNotSpecification<number, NumberComparatorVisitor>): void; (specification: OrSpecification<...>): void; (specification: OrNotSpecification<...>): void; (specification: NotSpecification<...>): void; }'.
      Types of parameters 'specification' and 'specification' are incompatible.
        Type 'AndSpecification<number, NumberComparatorVisitor>' is not assignable to type 'GreaterThan'.ts(2430)

Type 'NumberComparatorVisitor' does not satisfy the constraint 'SpecificationVisitor<NumberComparatorVisitor, number>'.
  Types of property 'visit' are incompatible.
    Type '(specification: GreaterThan) => void' is not assignable to type '{ (specification: AndSpecification<number, NumberComparatorVisitor>): void; (specification: AndNotSpecification<number, NumberComparatorVisitor>): void; (specification: OrSpecification<...>): void; (specification: OrNotSpecification<...>): void; (specification: NotSpecification<...>): void; }'.
      Types of parameters 'specification' and 'specification' are incompatible.
        Property 'value' is missing in type 'AndSpecification<number, NumberComparatorVisitor>' but required in type 'GreaterThan'.ts(2344)

I don't quite understand why it is complaining. What do I need to change in order to get this to work the way I intend it to?


Solution

  • Wow, there's quite a bit of code in there. Let's pare this down to a minimal reproducible example which shows the same problem:

    interface Foo {
      ovld(x: string): number;
      ovld(x: number): boolean;
    }
    
    interface BadBar extends Foo {  // error!
      ovld(x: boolean): string;
    }
    

    Here you have a Foo interface with an overloaded method named ovld. This method has two call signatures; one which takes a string and another which takes a number. And we attempt to make the BadBar interface that extends it. Our intent is to add a third overload to ovld which takes a boolean. But it doesn't work! Why?


    The answer is that you cannot simply add overloads when you extend interfaces. Interface extension is not interface merging. By redeclaring ovld in the extended interface, you are telling the compiler to completely overwrite the type of ovld in Foo with the version in BadBar. And because the single call signature in BadBar is not assignable to the call signatures in Foo, that's an error, just like this is an error:

    interface XXX {
      prop: string;
    }
    interface YYY extends XXX { // error!
      prop: boolean; 
    }
    

    In both cases you are extending an interface incorrectly by taking an existing property or method and making an invalid change. The only acceptable changes are narrowing changes, such as changing a string to "a" | "b".


    So, if we can't add an overload merely by re-declaring ovld() in BadBar, how can we do it? One way is to use an indexed access type to get the existing call signatures from the parent interface, and then intersect it with the new call signature:

    interface GoodBar extends Foo {
      ovld: ((x: boolean) => string) & Foo["ovld"]
    } 
    /* (property) GoodBar.ovld: ((x: boolean) => string) & {
      (x: string): number;
      (x: number): boolean;
    } */
    

    The intersection X & Y will be seen as a valid narrowing of X, so the compiler error goes away. Furthermore, TypeScript considers the intersection of functions to be equivalent to overloads. And so the above gives you the new (x: boolean) => string call signature as the first overload, followed by the existing two overload signatures from Foo:

    declare const goodBar: GoodBar;
    goodBar.ovld(false).toUpperCase();
    goodBar.ovld("hello").toFixed();
    goodBar.ovld(123) === true;
    

    Hooray!


    That means your NumberComparatorVisitor might need to be changed to something like this:

    export interface NumberComparatorVisitor extends
      SpecificationVisitor<NumberComparatorVisitor, number> {
      visit: (
        ((specification: GreaterThan) => void) &
        SpecificationVisitor<NumberComparatorVisitor, number>["visit"]
      );
    }
    

    If I make that change then the errors go away, because now NumberComparatorVisitor really is a valid extension of its parent interface.

    Playground link to code