Search code examples
typescripttypescript-typingstypechecking

Possible problem in TypeScript's type checking


Please look at the following MWE: I declared a union type, and I forgot to add one of the types to the type predicate. This caused the function to return a value that is not a number, and I got no type error during compilation:

type Car = Skoda | GMC;
type Skoda = {tag: "Skoda"}
type GMC = {tag: "GMC"}

const isSkoda = (x: any): x is Skoda => x.tag === "Skoda";
const isGMC = (x: any): x is GMC => x.tag === "GMC"
const isCar = (x: any): x is Car => isSkoda(x) // here I "forgot" to add || isGMC(x)

const carToNum = (c: Car): number => 
    isCar(c) ? 1 :
    c

console.log(carToNum({tag: "GMC"}))

This is what is printed on the console:

{ tag: 'GMC' }

Is that a bug?


Solution

  • It's not a bug. TypeScript is behaving as intended; see microsoft/TypeScript#29980 for an authoritative source. The compiler is not smart enough to check the function body the way you want, which is why the type predicate feature exists to begin with.


    TypeScript automatically treats certain commonly written code as type guards, which narrow the apparent type of a variable or property. For example, if x is of type unknown, you can write if (typeof x === "string") { console.log(x.toUpperCase()); } and the compiler treats the (typeof x === "string") as a typeof type guard that narrows x to string inside the relevant code block.

    Unfortunately TypeScript cannot recognize everything users might do to narrow this way. As a contrived example, if you write if (["string"].includes(typeof x)) { console.log(x.toUpperCase()); } the compiler complains about x.toUpperCase because it simply does not interpret the ["string"].includes(typeof x) construction as a type guard.

    But users still want to write their own type checks. And that's why TypeScript introduced user-defined type guard functions. User-defined type guard functions let users refactor their custom type checking code into a boolean-returning function whose return type is explicitly annotated with a type predicate. So while the compiler will never understand ["string"].includes(typeof x), you can still mark this code as a type guard if you move it to a function:

    function isString(x: unknown): x is string {
      return ["string"].includes(typeof x);
    }
    

    and then call that function like if (isString(x)) { console.log(x.toUpperCase()); } to get the narrowing you wanted.

    By annotating the return type as x is string, you are essentially asserting that the body of the function performs the right narrowing. The point of user-defined type guard functions is to tell the compiler something it can't figure out. (That's also why type assertions exist.)


    If the compiler were to complain inside the body of the function, like you want, it would often defeat the purpose of the function's existence in the first place. If the compiler were smart enough to notice that the function didn't do the narrowing properly you wouldn't have needed to write the function... or possibly you'd still want the function but you wouldn't have needed to give it a type predicate return type.

    That's why the feature request at microsoft/TypeScript#29980 to have the compiler check the body of user-defined type functions was declined as being too complex to implement. As mentioned in that issue by the TS dev team lead:

    For the most part we don't expect [user-defined type guard functions] to be checkable at all, because that's why the author is writing them in the first place. Trivial type guards which we would be able to check may as well be inlined anyway, and more error-prone type guards wouldn't be checkable anyway.

    Playground link to code