Search code examples
typescript

Can I make a TypeScript type guard apply against the result of a function overload?


I have some code that looks like this:

type UserTypes = "user" | "super-user" | "admin"  ;


function getUserType() : UserTypes {

}

function getAdminPrivs(userType: "user" | "super-user") : null  
function getAdminPrivs(userType: "admin") :  string 
// nb. we need to double declare the final line
// see: https://stackoverflow.com/questions/70146081/why-does-an-overloaded-function-declaration-sometimes-force-useless-type-narrowi
function getAdminPrivs(userType: UserTypes) : null | string
function getAdminPrivs(userType: UserTypes) : null | string {

  if(userType === "admin"){
    return "hello"
  }

  return null; 
}


// "user" | "super-user" | "admin"
const userType = getUserType();

// string| null  
const result = getAdminPrivs(userType);


if(userType === "user" || userType === "super-user"){

}
else {
  // string | null 
  // I really want this to be just string
  result
}

Here, what I'm wanting TypeScript to do is narrow the type of result, as a result of checking the type of userType. Is this possible?

As an example, if I switch my code around a little:

const userType = getUserType();
if(userType === "user" || userType === "super-user"){
  // null
  const result = getAdminPrivs(userType);
}
else {
  //string 
  const result = getAdminPrivs(userType);
}

This is kind of semantically the same, but we get the advantage of the type narrowing. (I can't use this technique in my actual use case, because the getAdminPrivs is actually a React hook and you're not allow to call React hooks conditionally)


Solution

  • TypeScript's narrowing only works in specific circumstances that were explicitly programmed into the type checker. Its control flow analysis is fairly powerful, but it only works in a "forward" direction. That is, a given check can narrow a value for the rest of the scope that follows the check, but it cannot go back and reinterpret previously analyzed code. That would scale very very badly for all but the simplest programs. Something like

    const userType = getUserType();
    if(userType === "user" || userType === "super-user"){
      const result = getAdminPrivs(userType); // null
    }
    else {
      const result = getAdminPrivs(userType); //string 
    }
    

    works because userType can be narrowed, and subsequent uses of userType reflect that narrowing. But

    const userType = getUserType();
    const result = getAdminPrivs(userType);
    if(userType === "user" || userType === "super-user"){
      result; // want null, but still string | null
    }
    else {
      result; // want string, but still string | null
    }
    

    cannot possibly work in TypeScript because it would have to narrow userType and then retroactively narrow userType in all previous utterances, then re-analyze the return type of getAdminPrivs, and re-analyze the type of result. It's easy enough for a human being to do such analysis because you know what you're looking for. But imagine how much compiler effort would go into analyzing an arbitrary program, where every check of any value can affect the types of that value for the whole program, which affects the types of anything that depends on that value, which affects the types of anything that depends on those values, et cetera.

    This basically can't happen in any scalable way, so it's a fundamental limitation of TypeScript.


    For sources for this limitation, see microsoft/TypeScript#41926, where the TS team dev lead says

    Control flow narrowing works based on looking for syntax that applies to the variable in question. In this example, nothing directly seems to influence [ result ] , so it has its unnarrowed type.

    We don't have the performance budget to do the sort of whole-universe counterfactual analysis that would be required to correctly detect this pattern.

    Also see this similar comment on microsoft/TypeScript#56221:

    Narrowing works by forming a control flow graph of things that might affect a variable's type, and checking those things to see if they're syntactic forms that look like they might cause something to be narrowed. It is not a constraint-solver style thing that can handle all kinds of other combinations and permutations of logical operators, so if your logic depends on a sort of counterfactual induction, it's not going to get picked up by narrowing. Thankfully this generally corresponds with more readable code in most cases.

    That last point is essentially saying that if you are writing code that TS can't analyze, it's probably harder for humans also, and if you rewrite it (e.g., by the redundant form you can't write because of the React hook limitation) both humans and TS will be happier.

    I don't know if the following would work in your use case, but you could re-package userType to be the discriminant property of a discriminated union, as shown here:

    type TypeAndPrivs =
      { userType: "user" | "super-user", adminPrivs: null } |
      { userType: "admin", adminPrivs: string }
    function getTypeAndAdminPrivs(userType: UserTypes): TypeAndPrivs {
      return (userType === "admin") ? { userType, adminPrivs: "hello" } : { userType, adminPrivs: null }
    }
    

    Then you could destructure that and use the results to give you the analysis you expected:

    const { userType, adminPrivs: result } = getTypeAndAdminPrivs(getUserType());
    if (userType === "user" || userType === "super-user") {
      result; // null
    }
    else {
      result; // string
    }
    

    Yes, it's still somewhat redundant, but at least you're not calling getAdminPrivs() multiple times.

    Playground link to code