10

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?

kayahr
  • 20,913
  • 29
  • 99
  • 147
  • This is cool idea! What TS version do you use? Your code works well in TS Playground https://www.typescriptlang.org/play/ right now. `let a2 = [1, 2, 'sdfsf', null].filter(isNotNull); // let a2: (string | number)[]` – Nail Achmedzhanov Feb 21 '19 at 13:11
  • I'm using latest Typescript 3.3.3 in strict mode. Enable at least the `strictNullChecks` option in TS Playground to reproduce the problem. It won't compile and hovering over `a2` in your example will show wrong type `never[]`. – kayahr Feb 21 '19 at 15:12
  • TS has a weakness in that you can't mix User-Defined Type Guards with standard return types. I'm not sure what you want to do is possible, and you'll need to define `isNotNull` from scratch rather than compose it. https://www.typescriptlang.org/docs/handbook/advanced-types.html#user-defined-type-guards – Matt R. Wilson Feb 21 '19 at 16:28
  • @kayahr, i think, it's compiler bug, because in non-strict mode compiler detect types as expected – Nail Achmedzhanov Feb 22 '19 at 09:28
  • Will this work(typescript predicate type) for you ```typescript // not(null) function isNotNull(input: T): input is NonNullable { return input != null; } ``` – cvss Jun 22 '21 at 06:11

2 Answers2

15

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);
kayahr
  • 20,913
  • 29
  • 99
  • 147
  • Have to add a comma so it isn't recognized as JSX. Change `isNull = ` and `isUndefined = ` to `isNull = ` and `isUndefined = ` – Zikoat Feb 28 '23 at 12:46
-1

Pass type information from context. This code is compiled well

// c: (string | number)[]
let c = [1, 2, 'b', 'a', null].filter(not<number | string | null, null>(isNull)); 
Nail Achmedzhanov
  • 1,264
  • 1
  • 9
  • 12
  • This defeats the whole purpose of the code. I want the compiler to infer the correct type automatically. Manually casting the result to specific types is error-prone. – kayahr Jul 19 '21 at 19:20