Search code examples
typescriptexpresstypes

Typescript: Variable inside Express RequestHandler becomes possibly undefined despite being checked in an if statement


I am creating a way to dynamically create routes before the server fully starts (meaning I'm not creating a route via a request lol that would be crazy), and I stumbled upon this interesting quirk.

Here is a minimal example. In the real program, there's "post", "patch", etc. in the args, a path in the args, middleware, etc., but the result is all the same:

export function createRoute(args: { search?: () => Promise<void> }) {
    if (args.search) {
        expressClient.get(
            "",
            async (req, res) => {
                await args.search(); // Cannot invoke an object which is possibly 'undefined'
            }
        );
    }
}

Why is this happening?


Solution

  • Problem

    I'm unsure about exact mechanics, but most likely this is happening because of how TS tries to prevent errors in concurrent execution. Say, in your example you've got 2 functions - createRoute and anonymous function passed as an argument to expressClient.get.

    So you check args.search inside createRoute and after that check, it's defined within it's function scope. However, anonymous function is still referencing the argument, which may be mutated after validation. Likely, this is why TS does ignore validation in createRoute function. I suggest a few solutions for type checking:

    Solution 1

    Make a copy of args.search function within createRoute and check against said copy. Something like this:

    export function createRoute(args: { search?: () => Promise<void> }) {
      // Here's the copy
      const searchCb = args.search;
      if (searchCb) {
        expressClient.get(
          "",
          async (req, res) => {
            await args.search();
          }
        );
      }
    }
    

    This way anonymous function is referencing a variable from parent function scope, which is guaranteed to be defined even after mutating args object. Which makes TS happy. Keep in mind, that you should make a copy, not pass a variable by reference (for example, when dealing with nested objects).

    Solution 2

    This solution is not really suitable for TNTzx, as he mentioned in the comments, but may be suitable for other people. Basically, you just check argument property is defined within your anonymous function. Something like this:

    export function createRoute(args: { search?: () => Promise<void> }) {
      return {
        function: async (req, res) => {
          if (args.search) {
            await args.search();
          }
        }
      }
    }
    

    Further thoughts

    Perhaps, such strict type validation is intentional to prevent errors in concurrent execution. As i mentioned before, args object may be mutated after the check within createRoute function and indeed be undefined when nested function tries to execute args.search.

    I can't find any documentation to support my claim. Also, setting search property as readonly does not get rid of an error. However, structuring the code like in the examples above, definitely makes it safer for concurrent execution.

    Interestingly enough, executing args.search within createRoute after validating is totally fine for TS:

    export async function createRoute(args: { readonly search?: () => Promise<void> }) {
      if (args.search) await args.search();
    }
    

    Keep in mind, if executed concurrently, this code still may be unsafe, because args object may be mutated just after validation, but before execution of args.search.