Search code examples
typescriptinterfacetypescript-typingsenforcement

No enforcement for method signature


If I define a method inside an interface and then implement it later on, it's parameters types aren't being enforced if the Interface was using method signature (it works for property signature).

example

it looks a lot like a bug, is it? is it intentional? is there a way to tackle it without changing all of our functions to property signatures?

it's different than this question since in the question, the interface's method accepts some parameter and the implementation can work without it (which is logical). But here the implementation expects to receive an expanded parameter which doesn't exist in the Interface


Solution

  • It's intentional. A little background about function parameters and variance (which you can skip if you already know this):

    If I ask for a function that accepts a string, and you give me a function that accepts unknown, I will be happy. I can safely pass that function a string and it will accept it because string is assignable to unknown. This is contravariance, because the direction of assignability switches: string is assignable to unknown, therefore (x: unknown) => void is assignable to (x: string) => void.

    Compare this to covariance, where the direction of assignability stays the same: "foo" is assignable to string, therefore is (x: "foo") => void assignable to (x: string) => void? No, it's not. If I ask for a function that accepts a string, and you give me a function that accepts only the string literal "foo", I will probably be unhappy if I use it. If I pass that function a string that doesn't happen to be "foo", then the function will not accept it.

    So it's type safe to check function parameters contravariantly, and not type safe to check them covariantly. So what does TypeScript do? Well, before TypeScript 2.6, the compiler actually allowed both. That is, it checked all function and method parameters bivariantly.

    Why would it do this? One of the issues has to do with methods that modify an object. Consider the example in the linked FAQ: the push() method of arrays. We usually want an string[] to be assignable to unknown[]. If I only read from such an array, this is perfectly safe. I ask for an unknown[]; you give me a string[]; I read from it, pulling out unknown values (which happen to be string objects but that's fine). But writing to the array is unsafe. If I call array.push(123), that should be fine if array is unknown[] but very bad if it is string[]. If the parameter to push() were checked contravariantly only, this would enforce the safety, disallowing string[] to be assignable to unknown[]. But, as they say in the FAQ, that would be "incredibly annoying". Instead, they allow the unsafe-yet-convenient convention of allowing function and method parameters to vary both ways.


    Back to the answer to this question: Luckily for those of us who like a little more type safety, TypeScript 2.6 introduced the --strictFunctionTypes flag which, when enabled, causes function parameter types to be checked only contravariantly instead of bivariantly.

    But there's still that issue with Array and other built-in classes that are often used covariantly. To prevent --strictFunctionTypes from being "incredibly annoying", the tradeoff is that function parameters are checked contravariantly, but method parameters are still checked bivariantly. So as of TS2.6, the compiler cares about the difference between method signatures and function signatures when it comes to type checking parameters.

    And so with the following interface:

    interface Example {
        func: (x: string) => void;
        method(x: string): void;
    }
    

    You get the following behavior (in TS2.6+ with --strictFunctionTypes enabled):

    const contravariant: Example = {
        func(x: unknown) { }, // okay!
        method: (x: unknown) => { } // okay!
    }
    
    const covariant: Example = {
        func(x: "foo") { }, // error!
        method: (x: "foo") => { } // okay!
    }
    

    Note that in contravariant and covariant, func is implemented as a method and method is implemented as a function-valued property, but they are still checked according to the rules in the Example interface: func has a function signature and is checked strictly, and method has a method signature and is checked loosely.


    So, is there a way to tackle it without changing all of your functions to property signatures? Probably nothing great. You might try to transform the signatures programmatically, like this:

    type MethodsToFunctionProps<T> = {
        [K in keyof T]: T[K] extends (...args: infer A) => infer R ? (...args: A) => R : T[K]
    }
    

    which works in this case:

    const covariantFixed: MethodsToFunctionProps<Example> = {
        func(x: "foo") { }, // error!
        method: (x: "foo") => { } // error!
    }
    

    but there may be edge cases so I don't know if that's worth it to you.


    Anyway, hope that helps; good luck!

    Link to code