Reputation: 2705
I have the following code (see it in action here):
type ParameterlessFunction = () => string;
type ParametrizedFunction = (params: string) => string;
let fn: ParameterlessFunction | ParametrizedFunction;
fn = (p) => `Hello ${p}`;
const calledWithParameter = fn("world");
console.log("calledWithParameter", calledWithParameter)
fn = () => `Goodbye`;
const calledWithoutParameter = fn();
console.log("calledWithoutParameter", calledWithoutParameter)
Since fn
can be either a parameterless or a parametrized function, the assignements to that variable don't throw any error. However, then I try to call fn()
, the compiler shows the following error:
Expected 1 arguments, but got 0. input.tsx(2, 30): An argument for 'params' was not provided.
Why is this happening?
Upvotes: 1
Views: 552
Reputation: 5112
It has to do with assignability. Before we begin, remember this:
If you have two types A and B, and B is a subtype of A, then you can safely use a B anywhere an A is required.
Also,
A function A is a subtype of function B if A has the same or lower arity (number of parameters) than B and:
- A's this type either isn't specified, or is >: B's this type.
- Each of A's parameters is >: its corresponding parameter in B.
- A's return type is <: B's return type.
with <: meaning covariant, and >: meaning contravariant
Note: these extracts are coming from the Programming Typescript book by Boris Cherny (O'reilly).
Moreover, quoting @jcalz in this thread:
And a union of function types can only safely take an intersection of their parameter types. This is working as intended, and this support was added in TS3.2.
If you have a function of type fnA | fnB, it means someone handed you either a fnA or a fnB but you don't know which it is. And if you have a value of type A | B, it means someone handed you an A or a B but you don't know which it is. You can't call the former function with the latter parameter... because you may have been handed a fnA and a B, or a fnB and an A. You don't know, so you can't do it.
On the other hand, if you've got an A & B value, you can pass it to a fnA | fnB function, because whether you've got a fnA or a fnB, the parameter is appropriate. It's both an A and a B.
Knowing all this, let's try to explain what's happening.
Since fn can be either a parameterless or a parametrized function, the assignements to that variable don't throw any error.
This is partly true. Actually, the function is assignable to both ParameterlessFunction
and ParametrizedFunction
.
Look at this little example here:
const foo = (a: (e: string) => string) => {
return a("foo")
}
foo(() => "bar") // no error, yet foo is expecting a function that takes a string argument. Yet the function we passed to foo had no arguments at all.
Why? Remember what we said above, we can safely pass and use any function that is subtype of the expected function type. Why is it safe? So long as the function is returning a string, whether you use the props or not is up to you. When we use Array.map(), we don't always use index, yet its signature is map<U>(callbackfn: (value: T, index: number, array: T[]) => U, thisArg?: any): U[];
Let's get back to your code:
fn = (p) => `Hello ${p}`; // p can only be string here
const calledWithParameter = fn("world");
console.log("calledWithParameter", calledWithParameter)
fn = () => `Goodbye`;
const calledWithoutParameter = fn(); // fn is still ParameterlessFunction | ParametrizedFunction since function with less arity is still assignable to (params: string) => string and the union of () => string and (params: string) => string is (params: string) => string
console.log("calledWithoutParameter", calledWithoutParameter)
So long as Typescript can't use something to discriminate between the union members, the type will remain ParameterlessFunction | ParametrizedFunction;
. As the function with no arguments are assignable to both, the type is still ParameterlessFunction | ParametrizedFunction;
. So the type of the expected arguments are string & (nothing), so string.
That's why you have the error you got.
Let's modify a bit the signatures:
type Foo = () => boolean;
type Bar = (params: string) => string;
let fn: Foo | Bar; // (params: string) => string | boolean
fn = (p: string) => `Hello ${p}`;
const calledWithParameter = fn("Joe"); // let fn: (params: string) => string
console.log("calledWithParameter", calledWithParameter)
fn = () => true;
const calledWithoutParameter = fn(); // let fn: () => boolean
console.log("calledWithoutParameter", calledWithoutParameter)
Notice that I didn't change the parameters. Their return types, however, are different. And now we don't have any errors. Why? Typescript checks assignability, it tries to assign the function to each member. Typescript now knows that the function is only assignable to Foo (we won't look at the parameters here since a function with no parameters can still be assigned to a function with parameters, the return type however is a boolean and that can only mean that it's a Foo and Foo expects no arguments, so no errors).
Hopefully, all that makes sense.
Upvotes: 1