Reputation: 6149
I've got the following minimal example that makes use of conditional types.
const wrap = <T extends boolean>(val: number, foo: T): T extends true ? {foo: number} : {bar: number} => {
if (foo) {
return {foo: val} as any;
}
return {bar: val} as any;
}
const test = Math.random() < 0.5;
const val = wrap(21, test);
if (test) {
console.log(val.foo);
} else {
console.log(val.bar);
}
(In playground).
It throws an error at both of the console.log
statements:
Property 'foo' does not exist on type '{ foo: number; } | { bar: number; }'.
Property 'foo' does not exist on type '{ bar: number; }'.(2339)
Property 'bar' does not exist on type '{ foo: number; } | { bar: number; }'.
Property 'bar' does not exist on type '{ foo: number; }'.(2339)
However if test is true, the output should only ever be {foo: number}
, and if it's false, it should only ever be {bar: number}
.
I am aware that I can use:
if ('foo' in val) {
console.log(val.foo);
} else {
console.log(val.bar);
}
But that seems to defeat the purpose of the conditional types in the function definition.
I'd like to know if there's a way to discriminate the type using the value input in the function instead of the value returned from the function. Any help is much appreciated.
Upvotes: 0
Views: 135
Reputation: 329248
I'd say this is a case of TypeScript's lack of support for what I've been calling "correlated union types". See microsoft/TypeScript#30581 for a discussion.
The compiler sees the type of test
as true | false
(which is displayed as boolean
, but is treated as the union of the two boolean literal types), and val
as type {bar: number} | {foo: number}
:
const test = Math.random() < 0.5; // boolean
const val = wrap(21, test); // {bar: number} | {foo: number}
These types are not wrong, but they are not very helpful for the kind of subsequent testing you are doing. The compiler sees the types of test
and val
as being completely uncorrelated; since each variable is a union of two members, the pair [test, val]
could be considered to be of the four-member union [true, {foo: number}] | [true, {bar: number}] | [false, {foo: number}] | [false, {bar: number}]
as far as the compiler knows. Any information about the correlation between test
and val
has been discarded by the compiler. It just does not keep track of this for you.
Indeed, if the compiler attempted to keep track of possible correlations between the types of every set of possibly-related variables, its performance would be catastrophically poor.
(TS 4.4 update: this next line is no longer strictly true, although the fix at microsoft/TypeScript#44730 still does not work on multiple correlated variables. See microsoft/TypeScript#12184 for a related request to keep track of such correlations with )boolean
variables, and the reply there is that it would probably be too complex to implement.
At some point I had suggested an "opt in" version of such analysis (see microsoft/TypeScript#25051) where you could tell it that the code block should be analyzed twice, once for each union member of test
. But this request was essentially declined.
For now, there just isn't any support for arbitrarily correlated union types. You will have to work around it.
The easiest workaround to the general case of correlated unions is to use type assertions; the compiler can't tell what type val
is, so you just tell it:
if (test) {
console.log((val as { foo: number }).foo);
} else {
console.log((val as { bar: number }).bar);
}
The other general solution involves repetition, to force the compiler to walk through the different possibilities via control flow analysis:
if (test) {
const val = wrap(21, test);
console.log(val.foo);
} else {
const val = wrap(21, test);
console.log(val.bar);
}
Neither of those are satisfying here... it would undoubtedly be better to refactor so that the compiler isn't expected to keep track of the relationship between test
and val
. Like your version:
if ("foo" in val) {
console.log(val.foo);
} else {
console.log(val.bar);
}
Along the same vein, you could refactor wrap()
so that its output is a true discriminated union where test
is actually stored in the output:
const discrimWrap = <T extends boolean>(val: number, test: T): (
T extends true ? { test: T, foo: number } : { test: T, bar: number }) => {
if (test) {
return { test, foo: val, } as any;
}
return { test, bar: val } as any;
}
Now instead of caring about the variable named test
, you can use the test
property of the wrapped output:
const discrimVal = discrimWrap(12, test);
if (discrimVal.test) {
console.log(discrimVal.foo);
} else {
console.log(discrimVal.bar);
}
Discriminated unions are one of the few places in TypeScript where it actually attempts to keep track of correlations. But they only work in specific circumstances, where the discriminant is a property of the object (and not a separate variable) and where it is a "singleton" or literal type (and not a wide type like string
or number
). And so this would be the refactoring I'd suggest here.
Finally, I would say that the "purpose of the conditional types in the function definition", as intended by the language designers, is not to be used to discriminate a test
of a union type like boolean
. When you pass a union in, a union comes out.
The purpose is to narrow the result type if the second argument is known by the compiler to be true
or false
via control flow analysis:
wrap(21, true).foo; // okay
wrap(21, false).bar; // okay
test ? wrap(21, test).foo : wrap(21, test).bar; // okay
That last line works because the compiler can narrow test
to true
and to false
in each of the last two operands of the ternary expression, respectively. But if you call wrap(21, test)
in a context where test
is only known to be boolean
, then the compiler loses any correlation.
Upvotes: 2
Reputation: 454
Does this implementation solve your issue?
function wrap(val: number, foo: true): { foo: number }
function wrap(val: number, foo: false): { bar: number }
function wrap(val: number, foo: boolean): { foo: number } | { bar: number } {
if (foo) return { foo: val }
if (bar) return { bar: val }
}
When calling,
wrap(2, true) // returns { foo: 2 }
wrap(2, false) // returns { bar: 2 }
Upvotes: 0