'In typescript, why do the | and & operators flip their meaning when used on function types?
In this code, example1
and example2
are confusing me:
type F1 = (a: string, b:string) => void;
type F2 = (a: number, b:number) => void;
// re: example 1 and 2:
// After the =, | means "or" and & means "and"
// Before the =, & means "or" and | means "and"
const example1: F1 & F2 = (a: string | number, b: string | number) => {}
example1("Hello", "World")
example1(1, 2)
// example1("Hello", 2) // Error! number is not assignable to parameter of type string... (and vice versa)
const example2: F1 | F2 = (a: string | number, b: string | number) => {}
// example2("Hello", "World") // Error! Argument of type string is not assignable to parameter of type never
// example2(1, 2) // Error! Argument of type number is not assignable to parameter of type never
// example2("Hello", 2) // Error! Argument of type string is not assignable to parameter of type never
// re: example 3,4,5:
// Before the =, | means "or"
// const example3: number | string = true // Error! Type Boolean is not assignable to type number | string
const example4: number | string = 1
const example5: number | string = "foo"
// re: example 6,7
// Before the =, & means "and"
// const example6: {a: string} & {b: number} = {a: "foo"} // Error! Type '{ a: string; }' is not assignable to type '{ a: string; } & { b: number; }'.
// Property 'b' is missing in type '{ a: string; }' but required in type '{ b: number; }'.
const example7: {a: string} & {b: number} = {a: "foo", b: 5}
To me it seems like the operators in example1
and example2
(before the =
) are behaving the opposite way from the others. Here's how I would expect these examples to work:
const example1: F1 & F2 = (a: string | number, b: string | number) => {}
// example2("Hello", "World") // Error! Argument of type string is not assignable to parameter of type never
// example2(1, 2) // Error! Argument of type number is not assignable to parameter of type never
// example2("Hello", 2) // Error! Argument of type string is not assignable to parameter of type never
const example2: F1 | F2 = (a: string | number, b: string | number) => {}
example1("Hello", "World")
example1(1, 2)
// example1("Hello", 2) // Error! number is not assignable to parameter of type string... (and vice versa)
It would also make sense to me if example1
didn't even compile, since "type of string !== type of number".
Why isn't this working as expected?
Solution 1:[1]
With these types,
type F1 = (a: string, b:string) => void;
type F2 = (a: number, b:number) => void;
and this declaration of example1
,
const example1: F1 & F2 = (a: string | number, b: string | number) => {}
example1
has declared type F1 & F2
, so it can be called both as a function of two strings and as a function of two numbers. But it can't be called with a mix of the two arguments. The function value you assigned to it could, but F1 & F2
is strictly a supertype of (a: string | number, b: string | number) => void
, so we lost information when we assigned to a variable with a static supertype, in the same way that assigning the number 3
to a variable of type unknown
loses information.
const example2: F1 | F2 = (a: string | number, b: string | number) => {}
The type of example2
is the type of either functions which can be called with string
arguments or those that can be called with number
arguments. The function you're assigning to it can be called with either, so the assignment is fine.
But we can never call this function. At all. We would have to pass it two arguments, where those arguments are compatible with both the F1
and F2
signatures. F1
expects string
and F2
expects number
, so we need to pass something that's both a string and a number, i.e. string & number
. And string & number
is never
, the empty type.
The reason the |
turns into an &
in that second function is due to a little thing called variance. Function arguments are contravariant, so ((a: A1) => B1) & ((a: A2) => B2)
is equal to (a: A1 | A2) => B1 & B2
and ((a: A1) => B1) | ((a: A2) => B2)
is equal to (a: A1 & A2) => B1 | B2
. You can read that Wikipedia page for more details on the math behind it, or write out what the "and" and "or" type means and follow your intuition.
Solution 2:[2]
It is true that the situation may be confusing regarding the arguments of types of function, when these functions are combined together.
@Bishwajitjha is not totally wrong in trying to explain it with the Set theory, even though simple diagrams may be enough.
Intersection F1 & F2
of function types
const example1: F1 & F2
example1
function can be called both as F1 and as F2. Therefore it corresponds to a TypeScript function overload:
- you can execute it as if it were an F1 (
example1("Hello", "World")
), - or as if it were an F2 (
example1(1, 2)
), - but you cannot mix (error for
example1("Hello", 2)
)
It is even easier to see how TypeScript handles the F1 & F2
intersection as an overload, if they had return types:
// Overload demonstration
type F1b = (a: string, b:string) => string;
type F2b = (a: number, b:number) => number;
declare const example1b: F1b & F2b;
const r1b1 = example1b("foo", "bar") // string
parseInt(r1b1) // Okay
r1b1.toFixed() // Error! Property 'toFixed' does not exist on type 'string'
const r1b2 = example1b(1, 2) // number
parseInt(r1b2) // Error! Argument of type 'number' is not assignable to parameter of type 'string'.
r1b2.toFixed() // Okay
declare const r1b3: ReturnType<F1b & F2b> // When TS must choose, it uses the last overload => number
parseInt(r1b3) // Error! Argument of type 'number' is not assignable to parameter of type 'string'.
r1b3.toFixed() // Okay
declare const r1b4: ReturnType<F2b & F1b> // When TS must choose, it uses the last overload => string
parseInt(r1b4) // Okay
r1b4.toFixed() // Error! Property 'toFixed' does not exist on type 'string'
Union F1 | F2
of function types
const example2: F1 | F2
Here it is more complex, because it does not match a specific TypeScript construct.
example2
may be just an F1, or just an F2, or an overload of both. Because the union does not provide any further information, when we try using example2
, we must account for all these possibilities simultaneously: https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#working-with-union-types
- as an F1, it would need both its arguments as
string
- as an F2, it would need both its arguments as
number
- as an F1 & F2, it can have either both as
string
or both asnumber
(previous section)
Therefore both arguments must be at the same time string
and number
, i.e. string & number
. Which is unfortunately never
as described by @SilvioMayolo.
Note that we could still have a "valid" call to example2
function:
// Using never type...
declare const arg: string & number; // Inferred as never
example2(arg, arg); // Okay!.. but should never be reached
At least, the return type (if they had one) follows the intuition:
declare const r2b: ReturnType<F1b | F2b> // string | number
parseInt(r2b) // Error! Argument of type 'string | number' is not assignable to parameter of type 'string'. Type 'number' is not assignable to type 'string'.
r2b.toFixed() // Error! Property 'toFixed' does not exist on type 'string | number'. Property 'toFixed' does not exist on type 'string'.
What about the actual function that was assigned?
Unfortunately, even though we assign an actual function that can accept more diverse argument types (it could even have been unions with boolean
, null
, an arbitrary type, etc.; provided that it can be called one way or another as an F1, or alternatively as an F2), we lose the more tolerant behaviour by explicitly specifying the type of example2
.
// Losing information of the actual function
const fn1d = (a: string | number | boolean, b: string | number | {a: number}) => {}
fn1d(true, {a: 1}) // Okay
const example1d: F1 & F2 = fn1d // Okay
example1d(true, {a: 1}) // Error! No overload matches this call. Overload 1 of 2, '(a: string, b: string): void', gave the following error. etc.
const example2d: F1 | F2 = fn1d // Okay
example2d(true, {a: 1}) // Error! Argument of type 'boolean' is not assignable to parameter of type 'never'.
In the case of F1 | F2
union, the situation would have been different if we had assigned a narrower type instead, e.g. (a: string, b: string) => {}
: here TypeScript would have followed the assignment, and inferred that we have a narrower type (as exactly described in https://www.typescriptlang.org/docs/handbook/2/narrowing.html#assignments)
// Narrower type assignment
const example2c: F1 | F2 = (a: string, b: string) => {}
example2c("Hello", "World") // Okay
example2c(1, 2) // Error! Argument of type 'number' is not assignable to parameter of type 'string'.
example2c("Hello", 2) // Error! Argument of type 'number' is not assignable to parameter of type 'string'.
What about example3
to example7
?
As pointed out by @SilvioMayolo's comment, these examples show how assignment is as expected.
Going further, we could describe their usage as well, but they would still be close to intuition:
declare const example8: {a: string} & {a: number}
const a = example8.a // never
parseInt(a) // Okay... but should never be reached
a.toFixed() // Property 'toFixed' does not exist on type 'never'.
declare const example9: {a: string} | {a: number}
const a9 = example9.a // string | number
parseInt(a9) // Error! Argument of type 'string | number' is not assignable to parameter of type 'string'. Type 'number' is not assignable to type 'string'.
a9.toFixed() // Error! Property 'toFixed' does not exist on type 'string | number'. Property 'toFixed' does not exist on type 'string'.
Only arguments of combined function types are really misleading.
Solution 3:[3]
As types are nothing bet sets, It all follows the Set theory.
First, let's understand a crucial behavior of &
in type system.
{ a: 'number' } & { b: 'string' } => { a: 'number', b: 'string' }
Both of the constituents types got added as they were independent types.
Keeping this in mind, let's try to prove this behavior and what should be the equivalent result of F1 & F2
and F1 | F2
type F1 = (a: string, b:string) => void;
type F2 = (a: number, b:number) => void;
Using SET Theory
Eq 1. (A | B) = A + B - (A & B)
Eq 2. (A & B) = A + B - (A | B)
Also, our F1 and F2 are independent type sets, hence
Eq 3. F1 & F2 => F1 + F2
Case 1: F1 & F2
> F1 + F2 ...(By using eq 3)
> (string, string) => void + (number, number) => void
> Basically means we can only call F1 and F2 with their own types (not their union one)
Case 2: F1 | F2
> F1 + F2 - (F1 & F2) ....(By Using Eqn 1)
> F1 + F2 - (F1 + F2) ....(By using Eqn 3)
> never (as everything cancels out)
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
Solution | Source |
---|---|
Solution 1 | Silvio Mayolo |
Solution 2 | |
Solution 3 | Bishwajit jha |