Search code examples
typescripttypescript-genericstypeguards

How to write an "isX" type guard for a generic type List<T> without knowing the type of T?


I am writing a typed List implementation in TypeScript, as a plain object whose structure is ensured using a type expression. I have chosen to write it as a plain object instead of a class, as I believe a plain object would more easily allow me to ensure its immutability. The type definition of this object is as follows:

type List<T> = Readonly<{
  head: T;
  tail: List<T> | void;
}>;

I would like to write a type guard for this structure so that I can ensure in other code that I am operating on a List object. My current type guard appears as follows:

export function isList(list: any): list is List {
  if (isNil(list)) {
    return false;
  }

  return ("head" in list && "tail" in list);
}

This implementation has an error on the first line, at list is List: TypeScript complains that the type List<T> is generic and, as such, I must provide a type in the reference to it.

The expected output of this function will be along the lines of:

import {getListSomehow, isList} from "list";

const list = getListSomehow("hello", "world", "etc...");
const arr = ["hello", "world", "etc..."];

console.log(isList(list)); // true
console.log(isList(arr)); // false
console.log(isList("a value")); // false

Is there any way to write this type guard without having to know the type of T? If not, is there a way to somehow retrieve this type, while still allowing the function to take any value?


Solution

  • This question exposes some of TypeScript's shortcomings. The short answer is: you probably can't do what you're trying to do, at least not in the way you're trying to do it. (We discussed this a bit in the comments to the question.)

    For type safety, TypeScript typically relies on compile-time type checking. Unlike in JavaScript, TypeScript identifiers have a type; sometimes this is given explicitly, other times it's inferred by the compiler. Generally, if you try to treat an identifier as a type that differs from its known type, the compiler will complain.

    This poses a problem for interfacing with existing JavaScript libraries. Identifiers in JavaScript don't have types. Furthermore, it's not possible to reliably check the type of a value at runtime.

    This led to the advent of type guards. It's possible to write a function in TypeScript whose purpose is to tell the compiler that if the function returns true, one of the arguments passed to it is known to be a specific type. This allows you to implement your own duck typing as a sort of liaison between JavaScript and TypeScript. A type guard function looks a bit like this:

    const isDuck = (x: any): x is Duck => looksLikeADuck(x) && quacksLikeADuck(x);
    

    This isn't a great solution, but it works as long as you're careful about how you check the type, and there aren't really any alternatives.

    However, type guards don't work well for generic types. Remember, the purpose of the type guard is to take an any input and determine whether it's a certain type. We can get part way there with generic types:

    function isList(list: any): list is List<any> {
        if (isNil(list)) {
            return false;
        }
    
        return "head" in list && "tail" in list;
    }
    

    This still isn't ideal, though. We can now test whether something is a List<any>, but we can't test for something more specific like List<number>--and without that, the result probably isn't going to be particularly useful to us, since all our encapsulated values are still of unknown types.

    What we really want is something that can check whether something is a List<T>. That gets a little trickier:

    function isList<T>(list: any): list is List<T> {
        if (isNil(list)) {
            return false;
        }
    
        if (!("head" in list && "tail" in list)) {
            return false;
        }
    
        return isT<T>(list.head) && (isNil(list.tail) || isList<T>(list.tail));
    }
    

    Now we have to define isT<T>:

    function isT<T>(x: any): x is T {
        // What goes here?
    }
    

    But we can't do that. We have no way to check at runtime whether a value is an arbitrary type. We can sort of hack our way around this:

    function isList<T>(list: any, isT: (any) => x is T): list is List<T> {
        if (isNil(list)) {
            return false;
        }
    
        if (!("head" in list && "tail" in list)) {
            return false;
        }
    
        return isT(list.head) && (isNil(list.tail) || isList<T>(list.tail, isT));
    }
    

    Now it's the caller's problem:

    function isListOfNumbers(list: any): list is List<number> {
        return isList<number>(list, (x): x is number => typeof x === "number");
    }
    

    None of this is ideal. If you can avoid it, you should; rely on TypeScript's strict type checking instead. I've provided examples, but first, we need an adjustment to the definition of List<T>:

    type List<T> = Readonly<null | {
        head: T;
        tail: List<T>;
    }>;
    

    Now, with that definition, instead of:

    function sum(list: any) {
        if (!isList<number>(list, (x): x is number => typeof x === "number")) {
            throw "Panic!";
        }
    
        // list is now a List<number>--unless we wrote our isList or isT implementations incorrectly.
    
        let result = 0;
    
        for (let x = list; x !== null; x = x.tail) {
            result += list.head;
        }
    
        return result;
    }
    

    Use:

    function sum(list: List<number>) {
        // list is a List<number>--unless someone called this function directly from JavaScript.
    
        let result = 0;
    
        for (let x = list; x !== null; x = x.tail) {
            result += list.head;
        }
    
        return result;
    }
    

    Of course, if you're writing a library or dealing with plain JavaScript, you may not have the luxury of strict type checking everywhere, but you should do your best to rely on it when you can.