npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

schematium

v0.6.0

Published

Type-safe schema / templating library for TypeScript — define, validate, and parse structured configurations with a fluent API.

Readme

Schematium

Type-safe schema & templating library for TypeScript — define, validate, and parse structured configurations with a fluent API.

Think of it as a simpler, lighter (sub 1.5KB minizipped) alternative to Zod - and one that's easier to extend.

Schematium lets you describe the shape of structured data (configs, CLI args, JSON payloads, environment inputs…) once, and then validate and parse values against that shape with full type inference.

See this example:

const template = schema({
  name: string("anonymous"), // optional, due to default value "anonymous"
  age: number().accepts((n) => n >= 0), // required, as no default provided
  role: oneOf("admin", "user"),
  variadicMember: valueOf(string, number, boolean).optional,
  permissions: recordOf({
    domain: string(),
    granted: arrayOf(oneOf("read", "write", "delete")),
  }).withDefault({}),
});

template.check(
  { age: 35, role: "admin" }, // → true (type guard)
);

template.validate(
  { age: 35, role: "admin" },
  { mode: "thorough" }, // → ValidationResult with detailed issues
);

Why Schematium?

  • End-to-end type safetyschema({...}) produces a value type you can use in function signatures, with optional vs. required fields tracked automatically.
  • Fluent, declarative API — chain .required, .optional, .accepts(...), .withDefault(...) to express constraints in the order you read them.
  • Fast by default — validation short-circuits on the first failure for hot paths, but you can switch into a mode that collects every issue with path-traced ValidationIssues using { mode: "thorough" }.
  • Zero dependencies at runtime.
  • Extensible — bring your own base class / decorator chain via generateTemplatingAPI(BaseClass).

Quick start

Installation

npm install schematium

Usage

//Pick what you need from the default entry point:
import {
    schema,                         // the main validation/parsing API function
    boolean, number, string, object // primitives
    array, arrayOf, record, recordOf, // collections
    oneOf, valueOf,                 // variadics
} from "schematium";

//You can define sub templates
const PostTemplate = {
    title: string(),
    content: string(),
};

const UserConfig = schema({
    name: string("anonymous"),                    // optional (has default)
    age: number().accepts((n) => n >= 0),         // required
    role: oneOf("admin", "user"),                 // required
    tags: arrayOf(string).withDefault([]),        // optional (has default)
    posts: recordOf(PostTemplate).withDefault({}) // optional (has default)
});

UserConfig.check({
    name: "Ada",
    age: 36,
    role: "admin",
    tags: ["founder"],
}); // → true

UserConfig.check({
    age: 36,
}); // → false (missing `role`)

// Detailed result with every issue path-traced:
UserConfig.validate(
    { age: 36 },
    { mode: "thorough" },
); // → ValidationResult { success: false, issues: [...] }

// Patch incoming incomplete objects with defaults:
const userInput = {
    age: 40,
    role: "user",
};

const newUserWithDefaults = Object.assign(UserConfig.getDefault(), userInput);

Concepts

Definition API vs. Template API

Every template in Schematium is backed by a single class, which exposes two complementary interfaces:

  • Definition API — the fluent, chainable surface you use while constructing a template (string(), number().required, arrayOf(...).withDefault([]), accepts(...), etc.). It lives on the value returned by the primitive, variadic, and collection factory functions.
  • Template API — the operational surface you use while consuming a template (.check(value), .validate(value, settings), .parseString(text, settings), .getDefault()). It is only exposed once an entire object schema is wrapped by schema(...).

Type References & Inference

In the definition API you have two distinct factory types to define your schema:

  • Value-array factories — They take default values and infer the resulting type from them — oneOf("admin", "user"), array([1, 2, 3]), record({ alice: "admin" }).
  • Type-array factories — They take other template factories as type descriptors and have no value to fall back on — valueOf(number, string), arrayOf(number, string), recordOf(number, string). Note that we only pass the functions, we do not invoke the factories. These are always required; if you want a default you have to call .withDefault(...) explicitly.

Understanding Defaults

Schematium lets you manage defaults. This is handy in case you get incomplete configuration/data and want to patch it with defaults. template.getDefault() produces an object containing all the paths that you have defined defaults for.

In combination with Object.assign you can use it to patch incoming incomplete objects. Because Object.assign would change the underlying defaults, Schematium always returns a fresh clone of the defaults when you call template.getDefault().

  • Defaults are cloned on read. getDefault() runs the stored default through structuredClone before returning it by default. If you'd like to return a shared default value, specify false as a second parameter to withDefault().
  • You can share default values by reference. Despite the standard being a structured clone, when you supply .withDefault(/* default value */, false) in the definition phase, Schematium will always inject a reference to this passed default in the tree produced by .getDefault(), instead of a clone.
  • Objects have implicit defaults. when object or schema are used and no .withDefault defines a default value, .getDefault() assembles a fresh object from all member defaults on every call. An explicit .withDefault({/* object */}) overrides that synthesis and pins the object reference.

Understanding Optionality

  • Types without defaults are required. Schematium distinguishes between two kinds of factory functions.
  • Defaults imply optionality. Passing a value to a primitive factory (string("anonymous")) or to a collection factory (array([]), record({})) or calling .withDefault(...) marks a value optional and adds a default.
  • .required and .optional override schematium's inferred optionality. The modifiers apply last-wins.
  • Objects with all optional members are inferred as optional. schema({...}) considers an object template optional only when every member is optional. The moment one member is required, the whole object becomes required too — so forgetting .required on a single nested field quietly turns the entire parent into an optional one.

Validation

Schematium exposes three runtime entry points on every schema(...) template:

| Method | Returns | Use when | | --------------------------------------- | --------------------------------- | -------------------------------------- | | template.check(value, tolerances?) | value is T (boolean type guard) | You want a fast boolean decision | | template.validate(value, settings?) | ValidationResult | You want every issue, with path traces, using {mode: "thorough"} | | template.parseString(text, settings?) | ParseResult<T> | Input arrives as a raw string |

Validation modes

ValidationSettings lets you switch between two modes:

  • "fastNoIssueReport" (default) — short-circuits on the first failure. validate() returns a result that only signals success/failure; no individual issues are collected.
  • "thorough" — keeps validating after the first failure. validate() and parseString() return a RejectionResult whose issues array contains one ValidationIssue per failure, each with path, message, and a kind derived from the issue class.

Validation tolerances

ValidationTolerances are accepted by every method (and form the base of ValidationSettings):

  • allowPartial — accept objects that don't have all the keys declared in the schema; only the types of the keys that are present are checked. Defaults to false.
  • allowUnknowns — accept objects that have additional keys not declared in the schema. Defaults to false.

Custom validators with issue reporting

accepts(...) and acceptsEntries(...) accept either a (value) => boolean predicate or a (value, validator) => void callback. The callback form gives you a ValidationAPI you can call validator.rejectWith(ValidationIssue, message) on to attach a custom issue to the result — which is only useful in "thorough" mode, since "fastNoIssueReport" discards issue detail.

Important: Inside these callbacks, do not throw exceptions to communicate validation failures. Throwing will abort validation entirely and bubble out of template.validate(...) / template.parseString(...), bypassing the result-based contract. Instead, signal failures by calling validator.rejectWith(ValidationIssue, "message") on the provided context — this keeps the failure inside the validation pipeline and lets the caller handle it as a normal RejectionResult:

const Port = number().accepts((value, validator) => {
  if (!(value > 0 && value < 65536)) {
    validator.rejectWith(ValidationIssue, "Port out of range");
  }
});

If you only need a yes/no decision and don't care about issue detail, prefer the boolean-returning form (accepts((value) => true | false)), which keeps your callback simple and works in both validation modes.

Interfaces

Definition API

Every value template supports these chainable modifiers:

  • .required — mark as required (overrides optionality from a default).
  • .optional — mark as optional (overrides a prior .required).
  • .accepts((value) => boolean) — install a custom validator (boolean form).
  • .accepts((value, validator) => void) — install a validator that can emit ValidationIssues via the ValidationAPI callback.
  • .withDefault(value, cloneOnAssign?) — set a default value and implicitly make it optional.
  • .acceptsEntries((key, value) => boolean) — for collections, validate each entry (boolean form).
  • .acceptsEntries((key, value, validator) => void) — entry-level validator with issue reporting.

Template API

  • template.check(value, tolerances?) — type-guard; returns value is T.
  • template.validate(value, settings?) — returns ValidationResult (ValidationSuccessResult or RejectionResult).
  • template.parseString(text, settings?) — returns ParseResult<T> (ParseSuccessResult<T> or RejectionResult).
  • template.getDefault() — returns the default tree (cloned by default).

parseString is particularly useful for CLI arguments and environment variables, which always arrive as strings. valueOf(number, string) automatically tries number first (parse-priority 0), then boolean (priority 1), then string (priority 2) — so "42" becomes 42 and "hello" stays "hello" regardless of the order you pass the types.

Types overview

Primitives

| Function | Description | | ------------------ | ------------------------------- | | string() | required string | | string(default) | optional string with default | | number() | required number | | number(default) | optional number with default | | boolean() | required boolean | | boolean(default) | optional boolean with default | | object({...}) | required nested object template |

Variadics

| Function | Description | | ------------------- | ---------------------------------------- | | valueOf(...types) | accepts any of the listed types | | oneOf(...values) | accepts any of the listed literal values |

Collections

| Function | Description | | ------------------------------- | ---------------------------------------------------------------------------- | | array(defaultArray, clone?) | optional T[] whose element type is inferred from the example | | arrayOf(...types) | required array of the listed element types | | record(defaultObject, clone?) | optional Record<string, T> whose element type is inferred from the example | | recordOf(...types) | required dictionary of the listed element types |

record/recordOf describe dictionaries (Record<string, T>). Keys are not constrained by the schema — only the value type is. Use .acceptsEntries((key, value) => ...) to add per-entry rules.

Examples

Validating a nested config

const Config = schema({
  server: {
    host: string("localhost"),
    port: number(8080).required.accepts((p) => p > 0 && p < 65536),
    tls: boolean(false),
  },
  features: arrayOf(string).withDefault([]),
});

Config.check({
  server: { port: 9000, tls: true },
  features: ["auth", "logging"],
}); // → true

Collecting every validation issue

const result = Config.validate(
  { server: { port: 9000, tls: "yes" }, features: "nope" },
  { mode: "thorough", allowUnknowns: true },
);

if (!result.success) {
  for (const issue of result.issues) {
    console.log(issue.kind, issue.path, issue.message);
  }
}

Parsing CLI args

import { oneOf, valueOf } from "schematium";

const Mode = oneOf("dev", "prod");
const Port = valueOf(number);

Mode.parseString(process.argv[2]); // { success: true, value: "dev" | "prod" }
Port.parseString(process.argv[3]); // { success: true, value: number } even though process.argv is string[]

Records with arbitrary keys

const Profiles = recordOf(string)
  .withDefault({})
  .acceptsEntries((key, value) => key.length > 0);

Profiles.check({ alice: "admin", bob: "user" }); // → true

Extending the API

schematium ships a default API instance, but the entire class hierarchy is generated by a factory — generateTemplatingAPI(BaseClass?) — so you can substitute your own base class and/or extend the fluent interfaces with your own methods. The extension entry point lives in schematium-extensible:

// Value imports
import {
  generateTemplatingAPI, // Customized API surface generator
  ValidationIssue, // issue class (for typed rejectWith)
} from "schematium/extensible";

// Type-only imports
import type {
  SchemaAPI, // shape/type of a fully-built schema(...)
  TemplateAPI, // shape/type of a fluent definition chain
  ValidationAPI, // shape of the validator callback API
  ValidationResult, // success / rejection union
  ValidationSettings, // per-call settings (tolerances + mode)
  ValidationTolerances, // per-call tolerance settings
  ValueType, // extract the value type from a definition
} from "schematium/extensible";

TemplateAPI is parameterized over five slots:

TemplateAPI<
  GeneralExt, // mixed into every template class
  SchemaExt, // mixed into the *Schema API* of schema(...) (which returns SchemaAPI<...>)
  PrimitiveExt, // mixed into the *Definition API* of string/number/boolean/object(...)
  VariadicExt, // mixed into the *Definition API* of valueOf/oneOf(...)
  CollectionExt // mixed into the *Definition API* of record/array/...
>;

Substituting the base class

Pass any class (or class-like constructor) as the first argument. The chosen base is inserted at the top of every template class hierarchy, so every template instance will instanceof your class and inherit its members.

class MyBase {
  metadata = "custom-base";
  getBaseInfo() {
    return "base-info";
  }
}

//Here we supply MyBase type as the generic for SchemaExt, so MyBase members are available on the Schema API
const api = generateTemplatingAPI<TemplateAPI<{}, MyBase>>(MyBase);

const t = api.templating.schema({
  sample: api.primitives.string("default").required,
});

t.metadata; // "custom-base"
t.getBaseInfo(); // "base-info"

If you only need a base class and want the default fluent shape, omit the generic argument:

class TrackingBase {
  calls: string[] = [];
  constructor() {
    this.calls.push("constructor");
  }
}

//Note that this Extension will neither be visible in the Definition API nor the Schema API
const api = generateTemplatingAPI(TrackingBase);

Extending the fluent interfaces

To add new chainable methods, declare a plain class whose members become part of the fluent API, then pass it as the appropriate TemplateAPI slot. The methods automatically return this, so they compose with the built-in modifiers (.required, .optional, .accepts(...), .withDefault(...), .acceptsEntries(...)).

import { generateTemplatingAPI, type TemplateAPI } from "schematium/extensible";

class Taggable {
  public tagValue?: string;
  tag(tag: string): this {
    this.tagValue = tag;
    return this;
  }
}

// Spread the extension into the primitive slot (we supply {} to not modify the Template API):
const { number } = generateTemplatingAPI<TemplateAPI<{}, {}, Taggable>>(
  Taggable,
);

const n = number(42).tag("my-number");
n.tagValue; // "my-number"
n.check(7); // still works — the built-in API is preserved

Writing definition methods that see the value's type

When your extension needs the concrete value type of the template it is attached to, use the ValueType<this> helper. It extracts the inferred value type from any definition-API surface, including variadics, so the same extension works on string(), valueOf(number, string), etc.

import {
  generateTemplatingAPI,
  type TemplateAPI,
  type ValueType,
} from "schematium/extensible";

class Extension {
  typeDependentClosure(closure: (value: ValueType<this>) => boolean) {
    return this;
  }
}

// Apply the extension to every slot — primitives, variadics, and collections.
const { number, string, valueOf } = generateTemplatingAPI<
  TemplateAPI<{}, Extension, Extension, Extension, Extension>
>(Extension);

number(42)
  .typeDependentClosure((value: number) => true); // ok
// .typeDependentClosure((value: boolean) => true);  // type error

valueOf(number, string)
  .typeDependentClosure((value: string | number) => true); // ok

This is the recommended way to build reusable helpers (custom validators, formatters, telemetry tags, etc.) that stay fully type-safe across every kind of template.

License

MIT