Skip to content

Passing predicates to collection types

The collection types take a Criteria and keep, find or drop the entries that match it. A plain type matches with extends:

type R = TuplePlus.Filter<[1, { a: 1 }, 'x', object], object> // [{ a: 1 }, object]

extends can’t express every rule. For example, “keep the entries that are exactly object” is IsObject<T, { exact: true }>, and extends can’t say that. TypeScript has no higher-kinded types, so you can’t pass the generic IsObject itself, unapplied, as the criteria.

A type function fills that gap. Pass one as Criteria, and an entry matches when the function returns true:

type R = TuplePlus.Filter<[1, { a: 1 }, 'x', object], IsObject.$Fn> // [{ a: 1 }, object]
type R = TuplePlus.Filter<[1, { a: 1 }, 'x', object], IsObject.$Fn<{ exact: true }>> // [object]
type R = TuplePlus.Filter<[1, { a: 1 }, 'x', object], $Fn.Not<IsObject.$Fn>> // [1, 'x']
Type Tuple Array
TuplePlus.Filter, ArrayPlus.Filter
Filter / KeepMatch
TuplePlus.Find, ArrayPlus.Find, FindFirst
Some
DropMatch, TuplePlus.DropMatch, ArrayPlus.DropMatch

Filter on an array keeps the members of the element type that the function returns true for. It returns never[] when the function matches none of them:

type R = Filter<Array<1 | { a: 1 }>, IsObject.$Fn> // Array<{ a: 1 }>
type R = Filter<Array<1 | 2>, IsObject.$Fn> // never[]

A predicate that you can pass exposes .$Fn in its namespace. Its type parameters are the predicate’s options, so IsObject.$Fn<{ exact: true }> is IsObject with exact already applied. The options are checked the same way as on the predicate, so a misspelled key is an error (see unknown option keys).

Every one-input predicate has .$Fn:

  • special types: IsAny, IsNever, IsUnknown, IsVoid, IsAnyOrNever
  • primitives: IsString, IsStringLiteral, IsTemplateLiteral, IsNumber, IsNumberLiteral, IsNumeric, IsBigint, IsBigintLiteral, IsBoolean, IsTrue, IsFalse, IsSymbol, IsNull, IsUndefined
  • numeric: IsInteger, IsIntegerLiteral, IsPositive, IsPositiveLiteral, IsNegative, IsNegativeLiteral
  • structure: IsObject, IsArray, IsTuple, IsFunction, IsStrictFunction, IsUnion
  • union members: HasNull, HasUndefined, HasVoid
  • the IsNot… counterpart of each of the above that has one, such as IsNotObject and IsNotString
type R = TuplePlus.Filter<[1, 'a', 2.5], IsInteger.$Fn> // [1]
type R = TuplePlus.Find<[1, 'a'], IsNotNumber.$Fn> // 'a'

The two-input predicates Assignable, NotAssignable, Equal, HasKey and IsOptionalKey have .$Fn too. A function has one input, so only one of the two can be the entry. The entry is the value being checked, and the other input is fixed as .$Fn’s first type parameter, ahead of the options:

type R = TuplePlus.Filter<[1, number, 1], Equal.$Fn<1>> // [1, 1]
type R = TuplePlus.Find<[string, 1], Assignable.$Fn<number>> // 1
type R = TuplePlus.Filter<[{ a: 1 }, {}], HasKey.$Fn<'a'>> // [{ a: 1 }]

Equal.$Fn<X> is how you match an entry exactly, in Filter, Find and DropMatch. It is what Some’s 'strict' mode does, available everywhere.

For a predicate without a .$Fn, you can write your own (see Writing a type function).

Pass the .$Fn, not the predicate. A bare predicate is a generic type without its arguments, so TypeScript rejects it:

type R = TuplePlus.Filter<[1], IsObject>
// error TS2707: Generic type 'IsObject' requires between 1 and 2 type arguments.
  • A type function matches only when it returns exactly true. A boolean answer does not match. That is what a distributive predicate returns for a mixed union such as {} | 1. A never answer does not match either.
  • Find, Some on an array, Filter on an array, and DropMatch test each member of a union entry on its own, the same way they treat a plain type. DropMatch on a tuple treats its last entry as a whole.
  • Find’s widen and $widen options do not apply to a type function. Some’s Mode does not apply either.
  • A plain type keeps its old meaning. A type is a function only if it carries the '~type-plus/fn' brand, so a type of your own that happens to have in and out properties is still matched with extends.

$Fn.Not<F> returns false where F returns true, and the reverse. It leaves a boolean answer as boolean, so a mixed union matches neither F nor $Fn.Not<F>:

type R = $Fn.Apply<$Fn.Not<IsObject.$Fn>, 1> // true
type R = $Fn.Apply<$Fn.Not<IsObject.$Fn>, {} | 1> // boolean

If the predicate already has an IsNot counterpart, such as IsNotObject, use that counterpart instead. It is cheaper.

A type function is an interface that extends $Fn and computes out from this['in']. $Fn.Apply<F, A> calls it:

import type { $Fn, TuplePlus } from 'type-plus'
interface IsOne extends $Fn {
readonly out: this['in'] extends 1 ? true : false
}
type R = $Fn.Apply<IsOne, 1> // true
type R = TuplePlus.Filter<[1, 2, 1], IsOne> // [1, 1]

$Fn.Apply<F, A> is (F & { readonly in: A })['out']. The intersection sets in, and this refers to the intersection, so out is computed from A.

To give your function options, add type parameters and forward them. Constrain them with $StrictOptions, as IsObject.$Fn does:

import type { $Fn as $FnBase, $StrictOptions, IsString } from 'type-plus'
interface IsStringFn<$O extends $StrictOptions<$O, IsString.$Options> = {}> extends $FnBase {
readonly out: IsString<this['in'], $O>
}

$Fn.Apply rejects a type that is not a $Fn:

type R = $Fn.Apply<1, 1>
// error TS2344: Type 'number' does not satisfy the constraint '$Fn'.

A plain type costs about 10 more instantiations per TuplePlus.Filter call on a 10-entry tuple than before type functions existed. A type function costs about 6 instantiations per entry, plus whatever the predicate itself costs. For example, IsObject costs about 24 per call and IsObject with exact about 140. An IsNot… counterpart costs about 110 fewer instantiations per call than $Fn.Not over the same predicate. For example, filtering a 10-entry tuple costs about 972 with IsNotString.$Fn and 1089 with $Fn.Not<IsString.$Fn>. All figures are for TypeScript 6.0.