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:
Refinement<A, B extends A> solves this. It packages runtime type-narrowing into a first-class, reusable data structure:
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.
Creating Refinements
Section titled “Creating Refinements”To lift a standard boolean check into a typed Refinement, we use Refinement.make:
Composing and Intersecting
Section titled “Composing and Intersecting”Because refinements are first-class values, we can combine them using logical operations without writing manual function wrappers.
Chaining in sequence with compose
Section titled “Chaining in sequence with compose”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:
Intersecting with and
Section titled “Intersecting with and”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:
Union branching with or
Section titled “Union branching with or”or combines two refinements, producing a union type B | C that passes if at least one of the two checks succeeds:
Bridging to Pipelines
Section titled “Bridging to Pipelines”Refinements integrate seamlessly into your data transformation flows.
Entering Maybe contexts with toFilter
Section titled “Entering Maybe contexts with toFilter”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:
Entering Result contexts with toResult
Section titled “Entering Result contexts with toResult”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:
This makes validating nested constraints inside a Result.chain extremely elegant:
When to use Refinement
Section titled “When to use Refinement”Use Refinement when:
Section titled “Use Refinement when:”- 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
MaybeorResultflows usingtoFilterortoResult.
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.