Search code examples
typescriptinterfacetypeerrorcovariancecontravariance

TypeScript Interface Function Field Contravariance Type Error


I have the following type, which establishes that all properties are going to be functions, which accept either no arguments, or a single argument of type Record<string, any>:

type FnTrait = Record<
  string,
  (input?: Record<string, any>) => any
>;

I attempt extend this type into another interface (which I want to have the same constraint).

interface HasFn extends FnTrait {
  someFn(): string; // no problem
  anotherFn(o: {id: string}): number; // ts error 2411
}

This produces an error on anotherFn: Property 'anotherFn' of type '(o: { id: string; }) => number' is not assignable to string index type '(input?: Record<string | number | symbol, any> | undefined) => any'.

Why is it that someFn produces no error, while anotherFn produces ts error 2411? It seems that this narrowing should be allowed.

Any help would be greatly appreciated. Thank you!


Solution

  • This is an instance of function types being contravariant in their parameters. The word "contravariant" means "varies in the opposite way". If you make a function's parameter more specific (narrow), you are making the function type itself more general (wide). That means instead of making HasFn a subtype of FnTrait (which is what extends means), you are sort of making it a supertype. This is not allowed, and violates the principle of substitutability.

    In particular, anotherFn() is in error because it requires that its argument have a string-valued id property, while FnTrait["anotherFn"] does not. It is expected that you can call any property of a FnTrait with either no parameters, or with a single parameter of just about any type. But a HasFn might explode if you call its anotherFn() method without a parameter, or with a parameter missing the right sort of id property. Therefore, as defined, HasFn is not assignable to FnTrait, despite being declared to extend it:

    const hasFn: HasFn = {
      someFn: () => "",
      anotherFn: o => o.id.length
    }
    const fnTrait: FnTrait = hasFn;
    fnTrait.anotherFn({ a: 123 }); // okay at compile time, explodes at runtime
    

    Since anotherFn() means you can't safely substitute a FnTrait value where a HasFn value is requested, FnTrait fails to be assignable to HasFn, and you get an error.


    The reason why someFn() is not in error is because a function of fewer parameters is assignable to a function that takes more parameters. This is because someFn() will, by necessity, ignore any parameters passed into it, so it is safe to treat it as a function which might possibly receive a parameter:

    fnTrait.someFn({ a: 123 }); // okay at compile time and runtime
    

    This works for the same reason anotherFn() fails: substitutability.

    Playground link to code