p-lens
v1.0.3
Published
**Composable, type-safe pattern matching and runtime validation — with zero dependencies and full TypeScript inference.**
Readme
p-lens
Composable, type-safe pattern matching and runtime validation — with zero dependencies and full TypeScript inference.
No more as unknown as YourType. No more hand-rolled type guards. No more any assertions at API boundaries. The matching module gives you a single, expressive API to describe the shape of your data, validate it at runtime, and have TypeScript automatically infer the correct type — all in one shot.
Quick start
import { pattern, find } from "p-lens";
const UserPattern = pattern.mapping({
name: pattern.string(),
age: pattern.anno.min(pattern.number(), 18),
role: pattern.union(pattern.literal("admin"), pattern.literal("user")),
nickname: pattern.anno.optional(pattern.string()),
});
// type guard — narrows unknown → inferred type
if (pattern.is.matching(data, UserPattern)) {
console.log(data.name); // TypeScript knows this is a string
}
// validated result with error path on failure
const result = pattern.is.validated(data, UserPattern);
if (!result.ok) {
console.error(result.path, result.reason);
}Why this?
- Zero schema duplication. Define once, get both runtime validation and compile-time types.
- Precise error reporting. Failures include a
path(e.g.["user", "address", "zip"]) so you know exactly where things went wrong. - Fuzzy matching built in. Validate "close enough" strings and numbers using Jaro-Winkler similarity — perfect for search, AI output parsing, and user input normalization.
- Deep search with
find(). Extract typed values from arbitrarily nested objects, arrays, Maps, and Sets in a single generator call. - Phantom types throughout. No casts needed — the inferred TypeScript type flows through every composition operation automatically.
Primitives
The fundamental building blocks. Each returns a pattern that both validates at runtime and informs TypeScript of the correct type.
pattern.string() // string
pattern.number() // number
pattern.boolean() // boolean
pattern.date() // Date
pattern.nil() // null (strict — undefined does NOT match)
pattern.none() // undefined
pattern.unknown() // unknown (always passes)Literals
Match an exact value. TypeScript infers the literal type.
pattern.literal("admin") // type: "admin"
pattern.literal(42) // type: 42
pattern.literal(true) // type: true
pattern.literal(new Date("2025-01-01")) // type: Date (exact timestamp)
// or the explicit variants:
pattern.literalString("hello")
pattern.literalNumber(0)
pattern.literalBoolean(false)
pattern.literalDate(someDate)Regex
Match strings against a regular expression. Full regex flag support — case-insensitive, multiline, whatever you need. On failure the error message includes the regex so you know what was expected.
const Digits = pattern.regex(/^\d+$/);
const Slug = pattern.regex(/^[a-z0-9-]+$/i);
pattern.is.matching("123", Digits) // true
pattern.is.matching("abc", Digits) // falseFuzzy matching
When "exactly equal" is too strict, fuzzy patterns let you express intent rather than precision. Both string and number variants are supported.
Fuzzy strings — Jaro-Winkler similarity
// Default threshold: 0.8
const Hello = pattern.fuzzyString("hello");
pattern.is.matching("hello", Hello) // true
pattern.is.matching("helo", Hello) // true — close enough
pattern.is.matching("world", Hello) // false
// Tune the threshold
const Strict = pattern.fuzzyString("hello", 0.99);
const Lenient = pattern.fuzzyString("hello", 0.5);
// Case-insensitive matching
const ICase = pattern.fuzzyString("Hello", { threshold: 0.8, icase: true });Fuzzy numbers — proximity score
Uses a 1 / (1 + |target - data|) score so values close to the target pass and outliers fail.
const Near10 = pattern.fuzzyNumber(10, 0.8);
pattern.is.matching(9.99, Near10) // true
pattern.is.matching(903.2, Near10) // falseShorthand: pattern.like
like dispatches to the right fuzzy variant based on the target type:
pattern.like("foo", 0.8) // → fuzzyString
pattern.like(10, 0.8) // → fuzzyNumberComposite patterns
Arrays
const Numbers = pattern.array(pattern.number());
// inferred type: number[]
const Matrix = pattern.array(pattern.array(pattern.boolean()));
// inferred type: boolean[][]Mappings (objects)
Describe the shape of a plain object. Extra keys are allowed and ignored. Missing required keys produce a failure with the exact key in the error path.
const Point = pattern.mapping({
x: pattern.number(),
y: pattern.number(),
});
// inferred type: { x: number; y: number }Unions
const StringOrNumber = pattern.union(pattern.string(), pattern.number());
// inferred type: string | number
// Discriminated unions work naturally with literals
const Event = pattern.union(
pattern.mapping({ type: pattern.literal("click"), x: pattern.number(), y: pattern.number() }),
pattern.mapping({ type: pattern.literal("keydown"), key: pattern.string() }),
);
// inferred type: { type: "click"; x: number; y: number } | { type: "keydown"; key: string }When a union fails, the reasons from all branches are collected and returned together so you understand every path that was tried.
Tuples
Fixed-length arrays where each position has its own type.
const Coord = pattern.tuple(pattern.number(), pattern.number());
// inferred type: [number, number]
const Row = pattern.tuple(
pattern.string(),
pattern.number(),
pattern.boolean(),
);
// inferred type: [string, number, boolean]Annotations
Annotations layer metadata and constraints onto any pattern without changing the core type. They compose cleanly with all other patterns.
optional — mark a mapping field as optional
const User = pattern.mapping({
name: pattern.string(),
nickname: pattern.anno.optional(pattern.string()),
});
// inferred type: { name: string; nickname?: string }min / max — range constraints
Works on strings (length), numbers (value), and dates (timestamp).
const Username = pattern.anno.min(pattern.anno.max(pattern.string(), 20), 3);
// must be 3–20 characters
const Age = pattern.anno.min(pattern.number(), 0);
const Score = pattern.anno.minmax(pattern.number(), 0, 100);
const RecentDate = pattern.anno.min(pattern.date(), Date.now() - 86400_000);title / description — documentation metadata
const Email = pattern.anno.titled(
pattern.anno.described(pattern.string(), "A valid email address"),
"Email",
);annotated — apply multiple annotations at once
const Percentage = pattern.anno.annotated(pattern.number(), {
title: "Percentage",
min: 0,
max: 100,
});Validation API
pattern.is.matching(data, pat) — type guard
Returns a boolean. Narrows unknown to the inferred type of the pattern.
const data: unknown = fetchSomething();
if (pattern.is.matching(data, User)) {
// data is { name: string; age: number; ... } here
}pattern.is.validated(data, pat) — detailed result
Returns a discriminated union with full error information on failure.
const result = pattern.is.validated(data, User);
if (result.ok) {
console.log(result.value); // typed as the pattern's inferred type
} else {
console.error({
path: result.path, // string[] — e.g. ["user", "address", "zip"]
reason: result.reason, // string[] — human-readable explanation(s)
});
}The path makes errors pinpoint-accurate. For deeply nested schemas you'll know exactly which field caused the failure without having to guess.
find() — deep search
This is where things get exciting. find traverses an arbitrarily nested data structure — objects, arrays, Maps, Sets, any combination — and yields every value that matches the given pattern. It's a generator, so results are lazy and you can stop early.
import { find } from "p-lens";
const data = {
users: [
{ name: "Alice", role: "admin" },
{ name: "Bob", role: "user" },
],
config: {
owner: { name: "Charlie", role: "admin" },
},
};
const AdminPattern = pattern.mapping({
name: pattern.string(),
role: pattern.literal("admin"),
});
for (const admin of find(data, AdminPattern)) {
console.log(admin.name); // "Alice", "Charlie" — fully typed
}find traverses into:
- Plain objects — recurses into all values
- Arrays — recurses into all elements
Map— recurses into all valuesSet— recurses into all values
It does not require you to know the structure ahead of time. Throw an entire API response, a config blob, or a parsed document at it and let the pattern do the work.
Real-world example: parsing AI output
const ToolCall = pattern.mapping({
tool: pattern.string(),
args: pattern.mapping({
query: pattern.string(),
}),
});
// response from an LLM might bury tool calls anywhere in the structure
for (const call of find(llmResponse, ToolCall)) {
await dispatch(call.tool, call.args.query);
}Stop early with break
Because find is a generator you get lazy evaluation for free:
const [first] = find(data, SomePattern); // only traverses until the first matchType inference
Every pattern carries its output type through a phantom type parameter, so InferPattern<P> gives you the TypeScript type without any casts:
import type { InferPattern } from "p-lens";
const UserPattern = pattern.mapping({
name: pattern.string(),
age: pattern.number(),
});
type User = InferPattern<typeof UserPattern>;
// { name: string; age: number }Optional fields, unions, tuples, literals — all flow through correctly. Define the pattern once and let the types derive themselves.
Putting it all together
const ApiResponse = pattern.mapping({
status: pattern.union(pattern.literal("ok"), pattern.literal("error")),
data: pattern.anno.optional(
pattern.array(
pattern.mapping({
id: pattern.anno.min(pattern.number(), 1),
label: pattern.anno.minmax(pattern.string(), 1, 100),
tags: pattern.array(pattern.string()),
})
)
),
error: pattern.anno.optional(pattern.string()),
});
type ApiResponse = InferPattern<typeof ApiResponse>;
function handleResponse(raw: unknown) {
const result = pattern.is.validated(raw, ApiResponse);
if (!result.ok) {
throw new Error(`Invalid response at ${result.path.join(".")}: ${result.reason.join(", ")}`);
}
return result.value; // fully typed ApiResponse
}