How to implement a guard function isUndefined
that only accept arguments whose type includes undefined
? The function should assert that the argument is undefined
.
Basic implementation:
function isUndefined(value: unknown): value is undefined {
return typeof value === 'undefined';
}
The expected behavior:
isUndefined(true) // <- Compilation error. argument type does not include undefined
const arg = true as boolean | undefined
isUndefined(arg) // <- No error. Argument type includes undefined.
// Compiler infers arg as undefined
arg // Hover arg in IDE => arg: undefined
It is possible to create a isUndefined
function that behaves similarly to what you described. In your "expected" example, you do nothing with the result of the call isUndefined(arg)
so the last value would never be inferred to be arg: undefined
. I will assume that this is just a mistake.
The problem
The function you are describing is supposed to be able to take a value that is a union that has to contain undefined
. This is not a common thing in typescript. As you may know typescript has generic functions, and you can restrict the parameters of generic functions using the extends
keyword. This is a basic example:
function fn<T extends number>(value: T): T {
return value;
}
fn(1 as number | string); // Errors
In this case, the function will accept anything that is at least number. Meaning that the call with number | string
will error.
However this is not what we are looking for. We need some sort of a constraint that will mean: "There has to be a possibility that the argument passed in will be undefined
".
The solution
In typescript this is achieved by flipping the extends constraint. This is a basic example:
type Test<T> = undefined extends T ? true : false;
type A = Test<1>;
// ^? type A = false
type B = Test<1 | undefined>;
// ^? type B = true
Of course, we cannot just do this in the generic type constraint for a function. However, we can use a little trick and use a helper generic parameter to get the result.
This is a working example:
function isUndefined<T extends R, R = undefined extends T ? T : never>(
value: T,
// @ts-ignore
): value is undefined {
return value === undefined;
}
isUndefined(true); // Errors
const arg = true as boolean | undefined;
if (isUndefined(arg)) {
console.log(arg);
// ^? const arg: undefined
} else {
console.log(arg);
// ^? const arg: boolean
}
The function is little involved so let's go over it.
We cannot flip the generic constrait directly in the parameter, so we are using a helper generic parameter R
which as you can see is not constraint in any way. If we try to constraint it like this ..., R extends T | never ...
we would get a circular reference error.
As you can see though, the R
parameter is being assigned a default value. In the value assignment, we can use the above mentioned technique, to set R
to eigher T
if undefined
is present in T
or never
otherwise.
We can then use this value of R
to constraint T
.
This does not error, because values are being set before the constraits are checked.
Why the @ts-ignore
Typescript tries to enforce that the type specified in the value is undefined
declaration (in our case undefined
) is even possible to be passed in to this function. However, due to the complex nature of our type definition, and R
not being constraint (which means that the caller could theoretically supply their own type for R
which would allow them to pass in a value which can never be undefined) typescript throws an error.
We cannot do something like isUndefined(value: T | undefined)
because that would break the inference of T
.
So as developers, we tell typescript to ignore the error, because we know better. And this is not a problem, because even if someone provided their own types, they would only get the ability to check if some value is undefined or not.
This is a walkthrough:
Example 1
Let's say we are passing in a value of boolean | undefined
Firstly, values are assigned. T
is assigned a value of boolean | undefined
(inferred from the argument) and R
is assigned a value of T
because undefined extends T
.
After that constraints are being checked. T
is supposed to extend R
which is equal to T
, therefore T
is supposed to extend T
which is valid.
Everything works!
Example 2
Let's say we are passing in a value of boolean
Firstly, values are assigned. T
is assigned a value of boolean
(inferred from the argument) and R
is assigned a value of never
because undefined
doesn't extend boolean
.
After that constraints are being checked. T
is supposed to extend R
which is equal to never
, therefore the constraint for T
fails.
Everything works!
Note
The error procuded by this solution can be quite cryptic for someone who doesn't understand typescript very well, therefore I would suggest putting a JS Doc comment on the function explaining the is not assignable to never
error.
Even better solution might be replacing the never
type with something like "The value passed in must have a chance of being undefined"
. This would also work, and the resulting error message would be as follows:
Argument of type
true
is not assignable to parameter of type"The value passed in must have a chance of being undefined"
Modified version
function isUndefined<
T extends R,
R = undefined extends T
? T
: "The value passed in must have a chance of being undefined",
>(
value: T,
// @ts-ignore
): value is undefined {
return value === undefined;
}