Search code examples
typescriptnarrowing

Typescript: How can I narrowing return type of a function?


type DequeNode<T> = {
  value: T;
  prev?: DequeNode<T>;
  next?: DequeNode<T>;
};

class Deque<T> {
  head?: DequeNode<T>;
  tail?: DequeNode<T>;
  size = 0;

  first() {
    return this.head?.value;
  }

  isEmpty() {
    return this.head == null
  }
}

const deque = new Deque<number>()
if (!deque.isEmpty()) {
  deque.first() // number | undefined
  deque.head?.value // number | undefined
}

if (deque.head) {
  deque.first() // number | undefined
  deque.head.value // number
}

Playground

In the above code, it can correctly infer type of deque.head.value if deque.head is not null. But it can't work when using deque.first()

How can I make isEmpty() check work and make first() return only type number?


Solution

  • The only way checking deque.isEmpty() would work to narrow the apparent type of deque is if it were a user-defined type guard method with a return type of the form this is ⋯. You could give a name to the type of a Deque<T> for which isEmpty() is true:

    interface EmptyDeque<T> extends Deque<T> {
      head?: undefined;
      first(): undefined;
    }
    

    and then make isEmpty() a type guard method:

    class Deque<T> {
      /* ✂ snip ✂ */
      isEmpty(): this is EmptyDeque<T> {
        return this.head == null
      }
    }
    

    But while this does what you want when you check deque.isEmpty() directly:

    const deque = new Deque<number>()
    if (deque.isEmpty()) {
      deque.first(); // undefined    
      deque.head; // undefined
    }
    

    It will not behave as expected when you check !deque.isEmpty():

    if (!deque.isEmpty()) {
      deque.first() // number | undefined
      deque.head?.value // number | undefined
    }
    

    And that's because TypeScript has no built-in mechanism to say that a Deque<T> which is not an EmptyDeque<T> has any special characteristics. You'd like to say that when isEmpty() returns false that it narrows Deque<T> to a NonEmptyDeque<T> defined like

    interface NonEmptyDeque<T> extends Deque<T> {
      head: DequeNode<T>;
      first(): T;
    }
    

    but there's no syntax for describing the false side of a type guard. There's a longstanding feature request for it at microsoft/TypeScript#15048, and if that is ever implemented maybe you could write

    class Deque<T> {
      // ⚠ THIS IS NOT VALID TS, DON'T TRY THIS ⚠
      isEmpty(): this is EmptyDeque<T> else this is NonEmptyDeque {
        return this.head == null
      }
    }
    

    and everything would behave as you want. But you can't do that, so we're stuck.


    Ideally you'd like to say that a Deque<T> is either an EmptyDeque<T> or a NonEmptyDeque<T>, meaning that conceptually it is a union type like

    type Deque<T> = EmptyDeque<T> | NonEmptyDeque<T>;
    

    If that were the case, then when you narrow a Deque<T> to prohibit EmptyDeque<T> with !deque.isEmpty(), then it would become a NonEmptyDeque<T> necessarily.

    But classes in TypeScript cannot be unions; or at least class declarations do not result in union-typed instances. You can describe the type of the constructor like

    const Deque: new<T>() => Deque<T> = class ⋯;
    

    but any such assignment will confuse the compiler, since the class constructor expression on the righthand side will not be seen as constructing a union. You'll need to use a type assertion like

    const Deque = class ⋯ as new<T>() => Deque<T>;
    

    and deal with the laxness in compiler verification by being very careful.

    So all that gives us

    interface BaseDeque<T> {
      isEmpty(): this is EmptyDeque<T>
      tail?: DequeNode<T>;
      size: number;
    }
    interface EmptyDeque<T> extends BaseDeque<T> {
      head?: undefined;    
      first(): undefined;
    }
    interface NonEmptyDeque<T> extends BaseDeque<T> {
      head: DequeNode<T>;
      first(): T;
    }
    type Deque<T> = EmptyDeque<T> | NonEmptyDeque<T>;
    class _Deque<T> {
      head?: DequeNode<T>;
      tail?: DequeNode<T>;
      size = 0;   
      first() {
        return this.head?.value;
      }
      isEmpty(): this is EmptyDeque<T> {
        return this.head == null
      }
    }
    const Deque = _Deque as new <T>() => Deque<T>; 
    

    which we can now test:

    const deque = new Deque<number>();
    const x = deque.first(); // const x: number | undefined 
    const y = deque.head?.value; // const y: number | undefined
    if (!deque.isEmpty()) {
      const x = deque.first(); // const x: number
      const y = deque.head.value; // const y: number
    } else {
      const x = deque.first(); // const x: undefined
      const y = deque.head; // undefined
    }
    

    This looks good and works as you want. But is it worth it?


    It really depends on your use case whether something like this is worthwhile. Personally if I needed to distinguish between these two cases at the type level and still use classes, I'd just write a BaseDeque<T> class and then have NonEmptyDeque<T> and EmptyDeque<T> subclasses, and use the union type Deque<T> elsewhere. This prevents the need for type assertions. But if we're refactoring like this we might as well avoid classes entirely since you could get by with plain objects also. The particular pros and cons of this are out of scope here, so I'll stop digressing.

    Playground link to code