It seems there is a discrepancy in how interfaces are checked when they are declared with properties versus methods. Specifically, assuming some object whose shape doesn't really matter:
interface ObjectInQuestion {
value: any
}
these interfaces:
interface FirstInterface {
callback(obj: Partial<ObjectInQuestion>): void;
}
interface SecondInterface {
callback: (obj: Partial<ObjectInQuestion>) => void;
}
typecheck differently:
function takesFirstInterfacePasses(_: FirstInterface) { }
function takesSecondInterfaceFails(_: SecondInterface) { }
function thisTakesEntireObject(_: ObjectInQuestion) { }
function whyDoTheseFunctionsCheckDifferently() {
takesFirstInterfacePasses({ callback: thisTakesEntireObject })
takesSecondInterfaceFails({ callback: thisTakesEntireObject })
}
Could someone help me understand why this is?
Here's a link to a repro in the TS Playground
CodePudding user response:
With the --strictFunctionTypes
compiler option enabled, the parameters of non-method functions are compared contravariantly for added type safety, while parameters of methods are still compared bivariantly to support convenient yet unsafe type hierarchies (see Difference between Variance, Covariance, Contravariance and Bivariance in TypeScript for a description of variance).
In
interface FirstInterface {
callback(obj: Partial<ObjectInQuestion>): void;
}
callback
is a method, so obj
is compared bivariantly, and thus takesFirstInterfacePasses({ callback: thisTakesEntireObject })
is accepted, even though thisTakesEntireObject
expects something more specific than Partial<ObjectInQuestion>
, a potentially unsafe operation.
Whereas in
interface SecondInterface {
callback: (obj: Partial<ObjectInQuestion>) => void;
}
callback
is a function-valued property, and thus obj
is checked contravariantly, and so takesSecondInterfaceFails({ callback: thisTakesEntireObject })
is rejected.
In case it matters, here's the type safety issue. Let's make the value
property more specific:
interface ObjectInQuestion {
value: string
}
And let's have thisTakesEntireObject()
actually do something that depends on that property being a string
:
function thisTakesEntireObject(_: ObjectInQuestion) {
console.log(_.value.toUpperCase())
}
And let's have your takesFirstInterfacePasses()
function actually call the callback
method on a partial object, which should work:
function takesFirstInterfacePasses(_: FirstInterface) {
console.log("calling")
_.callback({}) // RUNTIME ERROR: _.value is undefined
}
Then when you call whyDoTheseFunctionsCheckDifferently()
you will get a runtime error, when undefined
is checked for its non-existent toUpperCase()
method.