Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Type-safe predicate functions in TypeScript

My goal is to write predicate functions (isNull and isUndefined for example) in TypeScript which fulfill the following conditions:

  1. Can be used standalone: array.filter(isNull)
  2. Can be logically combined: array.filter(and(not(isNull), not(isUndefined)))
  3. Uses Type-guards so TypeScript knows for example that the return type of array.filter(isNull) will be null[]
  4. Combined predicates can be extracted into new predicate functions without breaking type inference: const isNotNull = not(isNull)

The first two conditions are easy to fulfill:

type Predicate = (i: any) => boolean;

const and = (p1: Predicate, p2: Predicate) =>
    (i: any) => p1(i) && p2(i);

const or = (p1: Predicate, p2: Predicate) =>
    (i: any) => p1(i) || p2(i);

const not = (p: Predicate) =>
    (i: any) => !p(i);

const isNull = (i: any) =>
    i === null;

const isUndefined = (i: any) =>
    i === undefined;

const items = [ "foo", null, 123, undefined, true ];
const filtered = items.filter(and(not(isNull), not(isUndefined)));
console.log(filtered);

But because no type-guards are used here TypeScript assumes that the variable filtered has the same type as items which is (string,number,boolean,null,undefined)[] while it actually now should be (string,number,boolean)[].

So I added some typescript magic:

type Diff<T, U> = T extends U ? never : T;

type Predicate<I, O extends I> = (i: I) => i is O;

const and = <I, O1 extends I, O2 extends I>(p1: Predicate<I, O1>, p2: Predicate<I, O2>) =>
    (i: I): i is (O1 & O2) => p1(i) && p2(i);

const or = <I, O1 extends I, O2 extends I>(p1: Predicate<I, O1>, p2: Predicate<I, O2>) =>
    (i: I): i is (O1 | O2) => p1(i) || p2(i);

const not = <I, O extends I>(p: Predicate<I, O>) =>
    (i: I): i is (Diff<I, O>) => !p(i);

const isNull = <I>(i: I | null): i is null =>
    i === null;

const isUndefined = <I>(i: I | undefined): i is undefined =>
    i === undefined;

Now it seems to work, filtered is correctly reduced to type (string,number,boolean)[].

But because not(isNull) might be used quite often I want to extract this into a new predicate function:

const isNotNull = not(isNull);

While this perfectly works at runtime unfortunately it doesn't compile (TypeScript 3.3.3 with strict mode enabled):

Argument of type '<I>(i: I | null) => i is null' is not assignable to parameter of type 'Predicate<{}, {}>'.
  Type predicate 'i is null' is not assignable to 'i is {}'.
    Type 'null' is not assignable to type '{}'.ts(2345)

So I guess while using the predicates as argument for the arrays filter method TypeScript can infer the type of I from the array but when extracting the predicate into a separate function then this no longer works and TypeScript falls back to the base object type {} which breaks everything.

Is there a way to fix this? Some trick to convince TypeScript to stick to the generic type I instead of resolving it to {} when defining the isNotNull function? Or is this a limitation of TypeScript and can't be done currently?

like image 623
kayahr Avatar asked Feb 21 '19 07:02

kayahr


1 Answers

Just found my own two year old question here and tried it again with latest TypeScript version (4.3.5) and the problem does no longer exist. The following code compiles fine and the types are correctly inferred:

type Diff<T, U> = T extends U ? never : T;

type Predicate<I, O extends I> = (i: I) => i is O;

const and = <I, O1 extends I, O2 extends I>(p1: Predicate<I, O1>, p2: Predicate<I, O2>) =>
    (i: I): i is (O1 & O2) => p1(i) && p2(i);

const or = <I, O1 extends I, O2 extends I>(p1: Predicate<I, O1>, p2: Predicate<I, O2>) =>
    (i: I): i is (O1 | O2) => p1(i) || p2(i);

const not = <I, O extends I>(p: Predicate<I, O>) =>
    (i: I): i is (Diff<I, O>) => !p(i);

const isNull = <I>(i: I | null): i is null =>
    i === null;

const isUndefined = <I>(i: I | undefined): i is undefined =>
    i === undefined;

const isNotNull = not(isNull);
const isNotUndefined = not(isUndefined);

const items = [ "foo", null, 123, undefined, true ];
const filtered = items.filter(and(isNotNull, isNotUndefined));
console.log(filtered);
like image 86
kayahr Avatar answered Oct 23 '22 03:10

kayahr