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 Node
s,
some of which might be Element
s. 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...
- Tell what type a type predicate
f(n)
checks for - 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
- If it is a type predicate, our return value is whatever type
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.