Skip to content

nlibjs/typing

Repository files navigation

@nlib/typing

TypeScript types that actually work at runtime.

Stop writing your types twice. @nlib/typing lets you define the shape of your data once — and use it for both compile-time type safety and runtime validation. No more keeping Zod schemas in sync with your interfaces. No more manual type guards that go stale.

// Define once...
const isUser = typeChecker({ id: isPositiveSafeInteger, name: isString });

// ...use as a type guard
if (isUser(data)) { /* data is User here */ }

// ...use for safe API responses
const user = ensure(await response.json(), isUser); // throws with a clear message if invalid

Why @nlib/typing?

  • Single source of truth — One definition drives both TypeScript types and runtime checks
  • Rich built-in validators — Email addresses, UUIDs, IPv4/IPv6, HTTP methods, Base64, URLs, and more — all with branded types for extra compile-time safety
  • Recursive types — Model trees and self-referencing structures with a simple getter pattern
  • Works everywhere — Node.js, Deno, and browsers (via esm.sh CDN)
  • Zero runtime overhead — Results are cached via WeakMap; the same definition always returns the same checker instance
  • Descriptive errorsensure() throws with the exact property path that failed validation

NPM Version JSR test codecov

npm install @nlib/typing
deno add jsr:@nlib/typing
import { typeChecker } from "@nlib/typing"
// Deno
import { typeChecker } from "jsr:@nlib/typing";
// Web
import { typeChecker } from 'https://esm.sh/@nlib/typing@3.0.0';

Bundle-sensitive applications can import the small core surface and individual checkers without loading the root barrel:

import { typeChecker, validate } from "@nlib/typing/core";
import { isUUIDLowercase } from "@nlib/typing/is/UUIDLowercase";

const isRequest = typeChecker({ id: isUUIDLowercase }, "Request");
const result = validate(
  { id: "123e4567-e89b-12d3-a456-426614174000" },
  isRequest,
);

Usage

import * as assert from "node:assert";
import {
  type Nominal,
  type NarrowingIssue,
  type TypeGuard,
  typeChecker,
  ensure,
  fromDiagnosis,
  narrow,
  union,
  validate,
  validateAll,
  ValidationIssueCode,
  isString,
  isPositiveSafeInteger,
  isArrayOf,
  isDictionaryOf,
  isObjectWith,
  isOptionalOf,
} from "@nlib/typing";

// For example, there is a interface named User.
interface User {
  id: number;
  name: string;
}

// You can create a TypeChecker for User with typeChecker().
const isUser = typeChecker({
  id: isPositiveSafeInteger,
  name: isString,
});
// typeChecker<T>() returns TypeChecker<T> which is a type guard for T.
// https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates
// i.e. TypeChecker<User> is (value: unknown) => value is User
assert.equal(isUser({ id: 1, name: "a" }), true);
assert.equal(isUser({ id: "1", name: "a" }), false);

// narrow composes a base TypeGuard with a reusable subtype predicate.
type EmailAddress = Nominal<string, "EmailAddress">;
const hasEmailAddressSyntax = (value: string): value is EmailAddress =>
  /^[^@]+@[^@]+$/.test(value);
const isContactEmail = narrow(isString, hasEmailAddressSyntax, () => [
  {
    code: "invalid_email_address",
    expected: "an email address containing @",
  },
]);
assert.equal(isContactEmail("ada@example.com"), true);
assert.deepEqual(validate("invalid", isContactEmail), {
  ok: false,
  issue: {
    path: [],
    code: "invalid_email_address",
    expected: "an email address containing @",
  },
});

// fromDiagnosis makes one generator the source of truth for both the boolean
// constraint and detailed structured-validation issues.
type Title = Nominal<string, "Title">;
function* diagnoseTitle(
  value: string,
  returnIssue?: NarrowingIssue,
): Iterable<NarrowingIssue> {
  const length = Array.from(value).length;
  if (length === 0) {
    yield returnIssue ?? {
      code: "too_short",
      expected: "at least 1 code point",
    };
  }
  if (100 < length) {
    yield returnIssue ?? {
      code: "too_long",
      expected: "at most 100 code points",
    };
  }
}
const isTitle: TypeGuard<Title> = typeChecker(
  narrow(isString, fromDiagnosis(diagnoseTitle)),
  "Title",
);
assert.equal(isTitle("A title"), true);
assert.equal(isTitle(""), false);

// Diagnosis paths are relative to the narrowed value. Structural checkers
// prefix them, and validateAll preserves every issue in iteration order.
type OrderedRange = { min: number; max: number } & {
  readonly orderedRange: unique symbol;
};
const isNumber = (input: unknown): input is number =>
  typeof input === "number";
const isRange = typeChecker({ min: isNumber, max: isNumber });
const hasOrderedBounds = (
  range: { min: number; max: number },
): range is OrderedRange => range.min <= range.max;
const isOrderedRange = narrow(isRange, hasOrderedBounds, () => [
  { path: ["min"], code: "range_min_after_max" },
  { path: ["max"], code: "range_max_before_min" },
]);
const isConfig = typeChecker({ range: isOrderedRange });
assert.deepEqual(validateAll({ range: { min: 2, max: 1 } }, isConfig), {
  ok: false,
  issues: [
    { path: ["range", "min"], code: "range_min_after_max" },
    { path: ["range", "max"], code: "range_max_before_min" },
  ],
});

// union OR-combines any TypeGuards and infers their guarded union.
const isCircle = typeChecker({
  kind: new Set(["circle"] as const),
  radius: isPositiveSafeInteger,
});
const isSquare = typeChecker({
  kind: new Set(["square"] as const),
  side: isPositiveSafeInteger,
});
const isShape = union(isCircle, isSquare);
assert.equal(isShape({ kind: "circle", radius: 2 }), true);
assert.equal(isShape({ kind: "triangle", side: 2 }), false);

// API responses commonly contain additional fields, so validate this one as
// an open shape with isObjectWith().
const isUserResponse = isObjectWith({
  id: isPositiveSafeInteger,
  name: isString,
});
const response = await fetch("https://jsonplaceholder.typicode.com/users/1");
const member = ensure(await response.json(), isUserResponse);
console.info(`member.id: ${member.id}`);
console.info(`member.name: ${member.name}`);

// validate returns the first machine-readable issue without throwing.
const validation = validate({ id: "1", name: "a" }, isUser);
assert.deepEqual(validation, {
  ok: false,
  issue: {
    path: ["id"],
    code: ValidationIssueCode.GuardFailed,
    expected: "TypeChecker<isPositiveSafeInteger>",
    actualType: "String",
  },
});

// validateAll collects every issue discoverable in structural checkers.
const allValidation = validateAll({ id: "1", name: 1 }, isUser);
assert.equal(allValidation.ok, false);
if (!allValidation.ok) {
  assert.deepEqual(
    allValidation.issues.map((issue) => issue.path),
    [["id"], ["name"]],
  );
}

// Exact objects report additional fields at their concrete paths.
assert.deepEqual(validate({ id: 1, name: "a", admin: true }, isUser), {
  ok: false,
  issue: {
    path: ["admin"],
    code: ValidationIssueCode.UnexpectedProperty,
    expected: "no additional properties",
    actualType: "Boolean",
  },
});

// isArrayOf returns TypeChecker<Array<T>>.
const isUserArray = isArrayOf(isUser);
assert.equal(
  isUserArray([
    { id: 1, name: "a" },
    { id: 2, name: "b" },
    { id: 3, name: "c" },
  ]),
  true,
);

// isDictionaryOf returns TypeChecker<Record<string, T>>.
const isUserDictionary = isDictionaryOf(isUser);
assert.equal(
  isUserDictionary({
    a: { id: 1, name: "a" },
    b: { id: 2, name: "b" },
    c: { id: 3, name: "c" },
  }),
  true,
);

// isOptionalOf returns TypeChecker<T | undefined>.
const isItem = typeChecker({
  id: isPositiveSafeInteger,
  name: isOptionalOf(isString),
});
assert.equal(isItem({ id: 1 }), true);
assert.equal(isItem({ id: 1, name: "a" }), true);
assert.equal(isItem({ id: 1, name: 1 }), false);

// Object definitions are exact by default. Unknown fields are rejected.
assert.equal(isUser({ id: 1, name: "a", admin: true }), false);

// isObjectWith keeps open-shape matching when additional fields are expected.
assert.equal(isUserResponse({ id: 1, name: "a", admin: true }), true);

// Example: Tree structure
interface Node {
  id: string;
  children?: Array<Node>;
}
const isNode = typeChecker({
  // You can use regular expression for defining a string pattern.
  id: /^[0-9a-z]+$/,
  // You can't use isNode here because isNode is not defined yet.
  // Use getter for recursive type definition.
  get children() {
    return isOptionalOf(isArrayOf(isNode));
  },
});
assert.equal(isNode({ id: "a" }), true);
assert.equal(isNode({ id: "a", children: [] }), true);
assert.equal(isNode({ id: "a", children: [{ id: "b" }] }), true);
assert.equal(
  isNode({ id: "a", children: [{ id: "b", children: [{ id: "c" }] }] }),
  true,
);
assert.equal(
  isNode({ id: "a", children: [{ id: "b", children: [{ id: "C" }] }] }),
  // id: "C" is invalid because it is not lowercase.
  false,
);

About

A tool for generating and managing TypeScript type definitions efficiently.

Topics

Resources

Stars

Watchers

Forks

Releases

Sponsor this project

Packages

Contributors

Languages