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?
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:
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).
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();
}
}
}
}
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
.