Today I ran into an unexpected TypeScript compiler behaviour. I\'m wondering if it\'s a bug or a feature. Probably it will be the last one, but then I would like to know the
The sad/funny truth about TypeScript is that it's not fully type-safe. Some features are intentionally unsound, in places where it was felt that soundness would be a hindrance to productivity. See "a note on soundness" in the TypeScript Handbook. You've run into one such feature: method parameter bivariance.
When you have a function or method type that accepts a parameter of type A
, the only type safe way to implement or extend it is to accept a parameter of a supertype B
of A
. This is known as parameter contravariance: if A
extends B
, then ((param: B) => void) extends ((param: A) => void)
. The subtype relationship for a function is the opposite of the subtype relationship for its parameters. So given { hello(value: string | number): void }
, it would be safe to implement it with { hello(value: string | number | boolean): void }
or { hello(value: unknown): void}
.
But you implemented it with { hello(value: string): void}
; the implementation is accepting a subtype of the declared parameter. That's covariance (the subtype relationship is the same for both the function and its parameters), and as you noted, that is unsafe. TypeScript accepts both the safe contravariant implementation and the unsafe covariant implementation: this is called bivariance.
So why is this allowed in methods? The answer is because a lot of commonly used types have covariant method parameters, and enforcing contravariance would cause such types to fail to form a subtype hierarchy. The motivating example from the FAQ entry on parameter bivariance is Array<T>
. It is incredibly convenient to think of Array<string>
as a subtype of, say, Array<string | number>
. After all, if you ask me for an Array<string | number>
, and I hand you ["a", "b", "c"]
, that should be acceptable, right? Well, not if you are strict about method parameters. After all, an Array<string | number>
should let you push(123)
to it, whereas an Array<string>
shouldn't. Method parameter covariance is allowed for this reason.
So what can you do? Before TypeScript 2.6, all functions acted this way. But then they introduced the --strictFunctionTypes compiler flag. If you enable that (and you should), then function parameter types are checked covariantly (safe), while method parameter types are still checked bivariantly (unsafe).
The difference between a function and a method in the type system is fairly subtle. The types { a(x: string): void }
and { a: (x: string) => void }
are the same except that in the first type a
is a method, and in the second, a
is a function-valued property. And therefore the x
in the first type will be checked bivariantly, and the x
in the second type will be checked contravariantly. Other than that, though, they behave essentially the same. You can implement a method as a function-valued property or vice versa.
That leads to the following potential solution to the issue here:
interface Foo {
hello: (value: string | number) => void
}
Now hello
is declared to be a function and not a method type. But the class implementation can still be a method. And now you get the expected error:
class FooClass implements Foo {
hello(value: string) { // error!
// ~~~~~
// string | number is not assignable to string
console.log(`hello ${value}`)
}
}
And if you leave it like that, you get an error later on:
const y: Foo = x; // error!
// ~
// FooClass is not a Foo
If you fix FooClass
so that hello()
accepts a supertype of string | number
, those errors go away:
class FooClass implements Foo {
hello(value: string | number | boolean) { // okay now
console.log(`hello ${value}`)
}
}
Okay, hope that helps; good luck!
Playground link to code