Home > database >  What causes this discrepancy in type checking behavior based on interface declaration style?
What causes this discrepancy in type checking behavior based on interface declaration style?

Time:12-28

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.

Playground link to code

  • Related