Skip to content

Instantly share code, notes, and snippets.

@harrygallagher4
Created March 29, 2023 04:15
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save harrygallagher4/3be1e435da23fcb97cd040e4250c52b7 to your computer and use it in GitHub Desktop.
Save harrygallagher4/3be1e435da23fcb97cd040e4250c52b7 to your computer and use it in GitHub Desktop.
TYPESCRIPT FUNCTION TYPES HAVE DRIVEN ME INSANE AND CAUSED ME TO WRITE A MAKESHIFT BLOG POST ABOUT THEM

TypeScript function types, type predicates, and higher-order functions

I started writing this as a Discord message but ended up needing to write a gist as it got longer so I apologize for the unprofessional language in the beginning

I've run into yet another problem that I've encountered before and it's time to rant about it because this one really bugs me. I think the fundamental problem is that TypeScript function types are extremely shitty. as far as I can tell, you can't express how the return type of a function depends on the type of the arguments.

In TypeScript you can define "type predicates" that are actually just JS functions with an additional "if this returns true, the return type is X, trust me bro" to the compiler.

// the 'node is Element' is what makes the compiler treat it as a type predicate
function isElement(node: Node): node is Element {
  return node instanceof Element;
}

In the DOM api, Node is a superclass of Element, an element is anything that looks like <a> and Node is mostly used for text content. So if you're iterating over an element's children you're working with a list of Nodes, some of which might be Elements. Anyway, since I'm writing a lot of different predicates for the parser I thought it would be nice to narrow the type of nodes when possible, so for a predicate that checks if a node is a <p> with certain content I may as well make it a type predicate that lets the compiler know we're dealing with an Element. This shit turned into an absolute nightmare and the most annoying part is that TypeScript seems to be capable of basically all of the different pieces here but they just aren't put together for some reason.

Here's a function that applies a predicate to a node:

// Node and Element are actually generics, I just used them to be readable
function test<Node, Element extends Node>(
  f: (x: Node) => x is Element,
  n: Node
): Element | null {
  return f(n) ? n : null;
}

It works fine. If you call it, the compiler knows that the return value will always be an Element or null, never a Node.

Even higher-order functions work

function isElementType(nodeType: string) {
  return function (n: Node): n is Element {
    return n instanceof Element && n.nodeName == nodeType;
  };
}

// the TypeScript compiler knows that this is a type predicate
const isP = isElement('P');

I wanted to write a function satisfy similar to test from above. It would take a predicate f(x) and a value n and return n if f(n) was true. So if f is a type predicate, the type of n (and thus the return value of satisfy) is whatever the type predicate checks for. To express the return value properly we need to be able to...

  1. Tell what type a type predicate f(n) checks for
  2. Define a type that "narrows" our type as much as possible. By this I mean that for a given predicate f(n)
    • If it is a type predicate, our return value is whatever type f checks for
    • Otherwise, our type is the same type f takes as an argument

I was kind of surprised that the first step is pretty straightforward in TypeScript and works basically how you'd expect it to.

// normal predicate function
type Predicate<T> = (x: T) => boolean;
// type predicate function
type TypePredicate<T, U extends T> = (x: T) => x is U;
// "utility" type that extracts the "narrowed" type
type TypePredicateType<Pred> =
  Pred extends Predicate<infer T>
    ? Pred extends TypePredicate<T, infer U>
        ? U
        : T
    : never;

// a type predicate
function isP(n: Node): n is Element {
  return n.nodeName == 'P';
}
// normal predicate
function isEmptyTextNode(n: Node) {
  return n.nodeValue?.trim() == '';
}

type IsPType = TypePredicateType<typeof isP>; // => Element
type IsEmptyType = TypePredicateType<typeof isEmptyTextNode> // => Node

And we can use that to express a return type

// test a type predicate
function tptTest<T, U extends T>(
  pred: TypePredicate<T, U>,
  n: T
): TypePredicateType<typeof pred> | null {
  if (pred(n)) return n;
  else return null;
}

const x = tptTest(isP, node); // => x: Element | null

// test a normal predicate
function pTest<T>(
  pred: Predicate<T>,
  n: T
): TypePredicateType<typeof pred> | null {
  if (pred(n)) return n;
  else return null;
}

const y = pTest(isEmptyTextNode, node); // y: Node | null

Now is where, unfortunately, it all falls apart. Given the type definitions above, a TypePredicate<T, U> is actually assignable to a Predicate<T> because they both just take an argument of type T and return a boolean. So in theory (or I guess in my opinion) we should actually be able to just use isP with a type predicate. Sadly, we can't.

const x = pTest(isP, node); // x: Node | null

Perhaps we just need to be a little more explicit.

function pTest<T, U extends T>(
  pred: Predicate<T> | TypePredicate<T, U>,
  n: T
): TypePredicateType<typeof pred> | null {
  if (pred(n)) return n;
  else return null;
}

const x = pTest(isP, node); // x: Element | Node | null

This one actually confuses me. Clearly the compiler knows we have passed a TypePredicate<Node, Element> and that the return value might be an Element but there is no branch where n is a Node and it gets returned.

I even tried being more explicit with the types by using interfaces. This makes ITypePredicate<T, U> explicitly a subtype of IPredicate<T>. I also explicitly added types to the predicates to make sure nothing was being inferred.

// TypePredicateType still works with these
// tptTest actually won't compile
// pTest always has the return signature: Node | null
interface IPredicate<T> {
  (arg: T): boolean;
}
interface ITypePredicate<T, U extends T> extends IPredicate<T> {
  (arg: T): arg is U;
}

// these don't appear to change anything
const isEmptyTextNode: Predicate<Node> = 
  (n: Node): boolean => n.nodeValue?.trim() == ''; 
const isP: TypePredicate<Node, Element> =
  (n: Node): n is Element => n.nodeName == 'P';

At that point I just accepted that it was probably not possible. I had run into what I'm pretty sure is the same issue before when trying to curry functions in TypeScript. When currying, you're dealing with a function that, for instance, takes up to 3 arguments. If it only receives one of them, it returns another function that takes two and this process is repeated until all 3 have been provided. Here are the type signatures in psuedocode:

f(x: string, y: string, z: string):
    string
f(x: string, y: string):
    g(z: string):
        string
f(x: string):
    g(y: string, z: string):
        string
    g(y: string):
        h(z: string):
            string

The issue is with the case where f only receives a single argument. It returns a function g which can either

  • receive two arguments and return a string
  • receive one argument and return another function that takes one argument and returns a string

So, f's return type can be determined just by looking at the number of arguments, not even their types. This is not expressible in TypeScript. The best you can do is specify that the return value is one of the following, all of which are very different!

  • a string
  • a function that takes one string and returns a string
  • a function that takes up to two arguments and returns
    • a string
    • a function that takes one string and returns a string

There is no way (as far as I can tell) to tell the compiler which return type will be produced even when it is extremely predictable.

I think the reason this whole scenario bothers me so much is that the TypeScript compiler seems almost there. The type system itself works really well and type predicates somewhat bridge the gap between compile time and runtime since types don't actually exist at runtime. I guess what is lacking is the typing of functions. Generics allow the compiler to determine the return type of a function based on the arguments to an extent but there isn't a way to tell the compiler about the behavior of a function, even though the compiler is clearly doing analysis of the different branches of functions so type predicates work correctly. The closest you can get is just using () => X | Y. I don't think it is very useful to know that a function returned one of two different things.

I don't even know how to conclude this, this was originally going to be a discord message but apparently you have to pay to send really long messages so it's a gist now (hello to anyone reading this who I didn't send it to on discord). Back to just using JavaScript because every time I try to write something in TypeScript I end up troubleshooting things that just aren't possible.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment