Skip to content

Refinement — Type Predicates

TypeScript features outstanding support for control flow analysis. When you write a runtime assertion like typeof x === "string" or x !== null, the compiler understands the condition and narrows the type of x inside that specific block.

However, these inline checks are highly localized. They do not compose.

If you write a type guard isNonEmpty(s) in one module and isValidEmail(s) in another, there is no native, clean way to combine them into a third type guard isNonEmptyAndValidEmail without writing a completely new, manual wrapper function and declaring a new type guard signature by hand. As validation rules grow, codebases accumulate a scattered array of redundant, ad-hoc guards:

// Works, but ad-hoc and completely un-composable:
function validateInput(raw: string) {
  if (raw.length > 0 && raw.includes("@")) {
    // The compiler knows the checks passed, but the combination is not named or reusable
    sendVerificationEmail(raw); 
  }
}

Refinement<A, B extends A> solves this. It packages runtime type-narrowing into a first-class, reusable data structure:

type Refinement<A, B extends A> = (a: A) => a is B;

At the runtime level, a Refinement is simply a standard function returning a boolean. At compile time, it is a type predicate. Because the refinement is a first-class value, we can compose, intersect, and combine checks point-free.


To lift a standard boolean check into a typed Refinement, we use Refinement.make:

import { Brand } from "@nlozgachev/pipelined/types";
import { Refinement } from "@nlozgachev/pipelined/core";

type PositiveNumber = Brand<"PositiveNumber", number>;
type EvenNumber     = Brand<"EvenNumber", number>;

const isPositive: Refinement<number, PositiveNumber> =
  Refinement.make((n) => n > 0);

const isEven: Refinement<number, EvenNumber> =
  Refinement.make((n) => n % 2 === 0);

isPositive(42);  // true  — 42 is narrowed to PositiveNumber
isPositive(-1);  // false

Because refinements are first-class values, we can combine them using logical operations without writing manual function wrappers.

When you have a pipeline of narrowing steps where the output of check A becomes the input of check B (e.g. string $\rightarrow$ NonEmpty $\rightarrow$ Trimmed), you can chain them using compose:

import { pipe } from "@nlozgachev/pipelined/composition";

type NonEmptyString = Brand<"NonEmpty", string>;
type TrimmedString  = Brand<"Trimmed", NonEmptyString>;

const isNonEmpty: Refinement<string, NonEmptyString> =
  Refinement.make((s) => s.length > 0);

const isTrimmed: Refinement<NonEmptyString, TrimmedString> =
  Refinement.make((s) => s === s.trim());

// string → NonEmptyString → TrimmedString collapses to a single string → TrimmedString check
const isNonEmptyTrimmed: Refinement<string, TrimmedString> = pipe(
  isNonEmpty,
  Refinement.compose(isTrimmed),
);

isNonEmptyTrimmed("hello");   // true
isNonEmptyTrimmed("  hello"); // false (fails isTrimmed)
isNonEmptyTrimmed("");        // false (fails isNonEmpty)

When two independent checks apply to the same base type and you require both to hold true simultaneously, and produces a combined B & C refinement:

type PositiveEven = PositiveNumber & EvenNumber;

const isPositiveEven: Refinement<number, PositiveEven> = pipe(
  isPositive,
  Refinement.and(isEven),
);

isPositiveEven(4);  // true
isPositiveEven(3);  // false (positive but odd)
isPositiveEven(-2); // false (even but negative)

or combines two refinements, producing a union type B | C that passes if at least one of the two checks succeeds:

interface User { role: string }
type AdminUser = User & { role: "admin" }
type SuperUser = User & { role: "super" }

const isAdmin: Refinement<User, AdminUser> = Refinement.make((u) => u.role === "admin");
const isSuper: Refinement<User, SuperUser> = Refinement.make((u) => u.role === "super");

const isPrivileged: Refinement<User, AdminUser | SuperUser> = pipe(
  isAdmin,
  Refinement.or(isSuper),
);

Refinements integrate seamlessly into your data transformation flows.

toFilter converts a Refinement<A, B> into a mapping function (a: A) => Maybe<B>. This is the standard gateway to validate inputs and lift them safely into an optional pipeline:

type NonEmptyString = Brand<"NonEmpty", string>;
const isNonEmpty: Refinement<string, NonEmptyString> = Refinement.make((s) => s.length > 0);

const parseTitle = (raw: string): Maybe<NonEmptyString> =>
  pipe(
    raw.trim(),
    Refinement.toFilter(isNonEmpty),
  ); // Some(NonEmptyString) or None

toResult converts a refinement into a mapping function (a: A) => Result<E, B>. It allows you to assert a type and attach a typed error if the check fails:

import { Result } from "@nlozgachev/pipelined/core";

const parseQuantity = (raw: number): Result<string, PositiveNumber> =>
  pipe(
    raw,
    Refinement.toResult(isPositive, (n) => `Quantity must be positive, got ${n}`),
  );

This makes validating nested constraints inside a Result.chain extremely elegant:

type EmailString = Brand<"Email", string>;
const isEmail: Refinement<NonEmptyString, EmailString> = Refinement.make((s) => s.includes("@"));

const validateEmailInput = (raw: string): Result<string, EmailString> =>
  pipe(
    raw,
    Refinement.toResult(isNonEmpty, () => "Email address cannot be empty"),
    Result.chain((nonEmpty) =>
      pipe(
        nonEmpty,
        Refinement.toResult(isEmail, () => "Email address must contain an '@' symbol"),
      )
    ),
  );

  • Modeling Domain Invariants: You want to represent clean guarantees (e.g. non-empty string, valid formatted input, positive number) as named, composable types.
  • Combining guards: You have multiple distinct checks and want to combine them point-free (and, or, compose) without writing manual function boilerplate.
  • Integrating with Pipelines: You want to feed assertions directly into Maybe or Result flows using toFilter or toResult.

Keep using Predicates or standard guards when:

Section titled “Keep using Predicates or standard guards when:”
  • Type narrowing is not needed: If you are combining basic filters (like checking if a number is greater than 10) and do not need the type system to carry a specialized nominal brand downstream, use Predicate<A> instead.
  • The check is strictly local: If you are performing a simple one-off check (e.g. typeof x === "string") in a single conditional statement that will never be composed or passed around, standard TypeScript guards are simpler and add zero overhead.