Issue
I'm trying to better understand typeguards. I think this is possible but I can't seem to get it working: if a parameter "foo" is true, then parameter "bar" is required to have a value.
Currently checking at runtime to accomplish the same thing:
function fn(foo: boolean, bar?: string) {
if (foo == true && bar == undefined)
throw new Error("if foo is true then bar can't be undefined")
}
I Thought TS would refuse to compile this, but it doesn't complain:
function fn<T extends boolean>(
foo: T,
bar: T extends true ? string : undefined
) {
if (foo == true)
bar.trim()
else
bar.trim() // compiler should error but doesn't
}
Thanks for your help!
Solution
If you want TypeScript to complain when you don't check for undefined
, you have to enable the --strictNullChecks
compiler option, which is also included in the --strict
suite of compiler features. If you're not using --strict
then you probably should, as it gives something like a "standard" or "recommended" level of type safety.
Right now you presumably have it disabled, which means the following is not an error:
const maybeString = (Math.random() < 0.5 ? "a" : undefined);
maybeString.toUpperCase(); // okay if --strictNullChecks is disabled
If you turn it on, you get the expected error:
maybeString.toUpperCase(); // error if --strictNullChecks is enabled
//~~~~~~~~~ <-- Object is possibly 'undefined'.
So from here on, we'll just consider the case where it's enabled:
Your fn()
function actually complains in both the true
and false
clauses of the if
statement, even though it seems impossible that the foo
could be true
while bar
is undefined
:
function fn<T extends boolean>(
foo: T,
bar: T extends true ? string : undefined
) {
if (foo == true)
bar.trim() // unexpected error here
else
bar.trim() // error as expected
}
There's a general limitation of TypeScript where the compiler cannot really understand most implications of types that depend on an unspecified or unresolved generic type parameter. Inside the body of fn()
, the type parameter T
is unspecified. It could be any type that extends boolean
, and the compiler generally treats T
either as just boolean
, eagerly substituting T
with its constraint); or, it has no idea what T
is and can't see anything as assignable to it, lazily deferring evaluation of T
entirely. The type of bar
, a conditional type involving T
, is deferred this way.
It doesn't do reasoning like "what if T
were true
here, what if T
were false
here", where it evaluates each line of code multiple times for different possible narrowings of T
. It can't use control flow anlaysis to narrow type parameters like T
. It mostly just gives up, as you've seen.
There are several open issues in GitHub about this sort of limitation; for example, microsoft/TypeScript#33912 asks for some improvement to evaluating generic conditional types. There's also microsoft/TypeScript#33014 and microsoft/TypeScript#27808 asking for a way to narrow type parameters.
If you look at some of those issues, it turns out that currently the compiler is correct to complain in your case. The type T
can be anything that extends boolean
. Now boolean
is actually a union type, a shorthand for true | false
. And while you might be expecting that means T
must be either true
or false
, that's not true. It can be any subtype of boolean
, including the full boolean
union itself. And if T
is boolean
, then foo
is boolean
and bar
is string | undefined
, and any constraint you thought you were enforcing on the callers is not really there:
fn(
Math.random() < 0.5,
Math.random() < 0.5 ? "" : undefined
) // no error
The compiler happily accepts this call (since T
is inferred as boolean
), and there's a 25% chance that foo
is true
while bar
is undefined
.
For now there is no type safe solution to the above GitHub issues (often people will use type assertions and move on with their lives). If you want type safety, we have to try a different approach.
Instead of using generics, we can try to use a discriminated union, which is a supported way to check one property of an object (the discriminant property) to learn things about other properties of the same object. If your foo
and bar
were properties of an object, then we could check foo
to learn about bar
.
It turns out that we can rewrite fn()
as a function that takes a rest parameter whose type is a discriminated union of tuple types:
function fn(...args: [foo: true, bar: string] | [foo: false, bar: undefined]) {
if (args[0] === true)
args[1].trim() // okay
else
args[1].trim() // complains as expected
}
The implementation is fully type safe now. Additionally, callers are forced to call it in one of the two supported ways and mix-and-match:
fn(true, ""); //okay
fn(false, undefined); // okay
fn(
Math.random() < 0.5,
Math.random() < 0.5 ? "" : undefined
) // complains as expected
As you saw, this isn't perfect. It would be nice if you could just assign args
to something like [foo, bar]
like const [foo, bar] = args
or function fn(...[foo, bar]: [true, string] | [false, undefined])
or something. But if you do that then foo
and bar
have been split apart into separate union-typed variables and the compiler loses the correlation between them.
TypeScript 4.4 introduced a feature where you are allowed to first assign the discriminant foo
to a separate variable and it will still work, so you can kind of get a bit closer:
function fn(...args: [foo: true, bar: string] | [foo: false, bar: undefined]) {
const foo = args[0];
if (foo === true) {
args[1].trim() // okay
} else {
args[1].trim() // complains as expected
}
}
But right now any attempt to do the same with bar
will fail to work right:
function fn(...[foo, bar]: [foo: true, bar: string] | [foo: false, bar: undefined]) {
if (foo === true) {
bar.trim() // error again
} else {
bar.trim() // error
}
}
It's possible that some upcoming TypeScript release will address this. In microsoft/TypeScript#44730, the pull request introducing this feature, it says:
In particular, the pattern of destructuring a discriminant property and a payload property into two local variables and expecting a coupling between the two is not supported as the control flow analyzer doesn't "see" the connection. ... We may be able to support that pattern later, but likely not in this PR.
So there's some hope for improvement in the future.
Answered By - jcalz
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.