Search code examples
node.jstypescripttype-assertiontype-narrowing

Enforce at compile time that a type is NOT narrowed


Inspired by this article I am using a pattern now that returns errors instead of throwing them (like in golang or fp-ts). I extended the code in the article with some more typing. This way it is possible to know at compile time which type of error is returned by a function.

const ERR = Symbol('ERR');
type Err<ErrType extends string> = {
  [ERR]: true;
  message: string;
  type: ErrType;
};

function isErr<ErrType extends string, Ok>(
  x: Ok | Err<ErrType>,
): x is Err<ErrType> {
  return typeof x === 'object' && x != null && ERR in x;
}

function Err<ErrType extends string>(
  message: string,
  type: ErrType,
): Err<ErrType> {
  return { [ERR]: true, message: message, type: type };
}

You can use the pattern like this (link to playgound):

function errIfFalse(input: boolean) {
    if (input) {
        return Err("input is false", "falseInput")
    }
    return "sucess"
}

function doSomething() {
    const a = errIfFalse(true)
    if (isErr(a)) {
        console.log("error: " + a.message)
        return a.type
    } 
    console.log(a)
    return "no error"
}

const a = doSomething()

So far it works, the return type of the function doSomething() is correctly infered to be one of the string literals "falseInput" and "no error". But I have one issue with the pattern: When the codebase is refactored it happens that a function that previosly returned an error, does not so anymore. Wrapping functions may then infer their return type wrongly. This is the case in the following example. That may cause confusing errors at other parts in the codebase.

function doSomething2() {
    const a = true
    if (isErr(a)) { // should throw compile time error because a is already narrowed down 
        // this code is unreachable but the compiler does not know that
        console.log("error: " + a.message)
        return a.type
    } 
    console.log(a)
    return "no error"
}

I would like to modify the type narrowing function isErr() in a way so that it only accepts input that is not already narrowed down. How can I achieve that?


Solution

  • The simplest approach that gets close to what you want looks like

    function isErr<T>(
      x: T,
    ): x is Extract<T, Err<any>> {
      return typeof x === 'object' && x != null && ERR in x;
    }
    

    Essentially instead of trying to require that the input be of a union of multiple generic types, we have it be just a single generic type T, and then compute the "Err part" of T by using the Extract utility type to filter the union. This immediately gives you the same behavior for good calls:

    function doSomething() {
      const a = errIfFalse(true)
      if (isErr(a)) {
        a // Error<"falseInput">
        console.log("error: " + a.message)
        return a.type
      }
      a // "sucess" 
      console.log(a)
      return "no error"
    }
    

    and for bad calls the compiler ends up narrowing the input to the impossible never type:

    function doSomething2() {
      const a = true
      if (isErr(a)) { // okay, but
        console.log("error: " + a.message) // error
        //              --------> ~~~~~~~
        //  Property 'message' does not exist on type 'never'.
        return a.type // error
        //   --> ~~~~
        // Property 'type' does not exist on type 'never'.
      }
      console.log(a)
      return "no error"
    }
    

    That's not what you asked for, but it is fairly clear that something went wrong.


    If you really need to prohibit calls unless the input could possibly be an Err, then you can use a generic constraint. Ideally you'd be able to give it a lower-bound constraint like Err<any> extends T or T super Err<any>, but TypeScript only supports upper-bound constraints directly. There's a longstanding open issue at microsoft/TypeScript#14520 for this, but until and unless that is implemented we need to work around it. You can often get away with simulating the constraint T super U by writing T extends (U extends T ? unknown : U), where the conditional type U extends T ? unknown : U will only constrain T if U extends T is not true. So that looks like

    function isErr<T extends (Err<any> extends T ? unknown : Err<any>)>(
      x: T,
    ): x is Extract<T, Err<any>> {
      return typeof x === 'object' && x != null && ERR in x;
    }
    

    Again, the good call is unaffected. But now the bad call does this:

    function doSomething2() {
      const a = true
      if (isErr(a)) { // error
        //  --> ~
        // Argument of type 'boolean' is not assignable to parameter of type 'Err<any>'.
        console.log("error: " + a.message)
        return a.type
      }
      console.log(a)
      return "no error"
    }
    

    This gives you an error where you want it.


    Neither form performs reachability analysis the way you wanted, but this is mostly just a limitation of TypeScript, or a missing feature, documented at microsoft/TypeScript#12825. Narrowing a value to never like this happens too late to be noticed by reachability analysis. Hopefully this limitation isn't a big deal, since you do get errors either way.

    Playground link to code