When it comes to assignability, there are some key principles to keep in mind:
If you have two types A and B, where B is a subtype of A, you can safely use a B wherever an A is required.
In addition, take note of the following:
A function A is considered a subtype of function B if A has the same or fewer parameters than B, and:
- The 'this' type of A is either unspecified, or a supertype of B's 'this' type.
- Each parameter in A is a supertype of its corresponding parameter in B.
- The return type of A is a subtype of B's return type.
Where '<:' denotes covariance and '>:' denotes contravariance.
It's worth mentioning that these insights are drawn from the book Programming Typescript by Boris Cherny (O'reilly).
Furthermore, as mentioned by @jcalz in this thread:
When dealing with a union of function types, you can only safely intersect their parameter types. This behavior was intentionally introduced in TS3.2. For instance, if you receive a fnA | fnB, you know you've been handed one of those functions but not sure which. Similarly, with a value of type A | B, you know it's either an A or B, without clarity on which. Thus, calling the former function with the latter parameter may result in ambiguity. However, when dealing with an A & B value, it can be passed to a fnA | fnB function because the parameter fits both types.
Armed with this knowledge, let's delve into deciphering what's going on.
Given that a function can be either parameterless or parametrized, assignments to such variables do not trigger errors.
This holds partly true, as the function can actually be assigned to both ParameterlessFunction
and ParametrizedFunction
.
Consider the following example:
const foo = (a: (e: string) => string) => {
return a("foo")
}
foo(() => "bar") // Despite expecting a function that takes a string argument, no error occurs since the passed function indeed returns a string.
Why? As explained earlier, any function that is a subtype of the expected function type can be used safely. Even if the passed function ignores certain arguments, as long as it returns the expected type – in this case, a string – the usage remains valid. Think of Array.map() where not all callbacks utilize the index despite its inclusion in the signature.
Returning to your code snippet:
fn = (p) => `Hello ${p}`;
const calledWithParameter = fn("world");
console.log("calledWithParameter", calledWithParameter)
fn = () => `Goodbye`;
const calledWithoutParameter = fn();
console.log("calledWithoutParameter", calledWithoutParameter)
The aforementioned error arises due to Typescript's inability to differentiate between union members like ParameterlessFunction
and ParametrizedFunction
. In scenarios where functions with varying argument counts are interchangeable, the union type persists. Consequently, the expected argument types resolve to string & empty, leading to the error notification.
To address this issue, consider adjusting the signatures slightly:
type Foo = () => boolean;
type Bar = (params: string) => string;
let fn: Foo | Bar;
fn = (p: string) => `Hello ${p}`;
const calledWithParameter = fn("Joe");
console.log("calledWithParameter", calledWithParameter)
fn = () => true;
const calledWithoutParameter = fn();
console.log("calledWithoutParameter", calledWithoutParameter)
By keeping the same parameters while altering the return types, we eliminate errors thanks to Typescript's meticulous assignability checks. In this context, the function aligns solely with Foo due to its boolean return type and zero-parameter expectation.
Hopefully, the clarification provided sheds light on the matter at hand.