typescript-typingstypescript-genericstypeguards

Limit argument types for guard function


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

Solution

  • 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
    }
    

    TSPlayground link

    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;
    }