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

@signaltree/schema

v11.0.0

Published

Schema-driven validation for SignalTree. StandardSchema-compatible, async-first, observe-only.

Readme

@signaltree/schema

Schema-driven validation for SignalTree. StandardSchema-compatible, async-first, observe-only.

import { signalTree } from '@signaltree/core';
import { schemas } from '@signaltree/schema';
import { z } from 'zod';

const tree = signalTree({ user: { email: '', age: 0 } }).with(
  schemas({
    schemas: {
      'user.email': z.string().email(),
      'user.age': z.number().int().min(0),
    },
  }),
);

tree.$.user.email.set('not-an-email');
tree.schemas.errorsAt('user.email')(); // 'Invalid email'
tree.schemas.isValid(); // false

Why this exists

  • One registry. Register your schemas in one place; read errors per path or in aggregate. No more drift between form validation, server-action checks, and ad-hoc Zod calls.
  • StandardSchema interop. Works with Zod, Valibot, ArkType, Effect Schema — anything that implements StandardSchemaV1.
  • Async-first. Schemas can return Promises. The write-sequence guard drops stale verdicts when a newer write supersedes an in-flight async run.
  • Observe-only. The enhancer never blocks writes. Verdicts populate signals; that's it. (See Why no reject mode below.)

What it does NOT do

  • Does not block writes. This is intentional — see §Why no reject mode.
  • Does not duplicate @signaltree/guardrails. Guardrails is about performance and anti-patterns; schema is about data-shape conformance. Different jobs.
  • Does not yet integrate with Angular Signal Forms. Signal Forms hasn't shipped in stable Angular yet. See spike result. Wire errorsAt(path) into your template manually for now.

Install

pnpm add @signaltree/schema @signaltree/core
# plus your schema library:
pnpm add zod    # or valibot, arktype, etc.

API

schemas(config)

Returns an enhancer. Apply via .with(schemas({...})).

interface SchemaConfig {
  schemas: Record<SchemaPath, StandardSchemaV1>;
  mode?: 'accept' | 'warn'; // default 'accept'
  validateOnAttach?: boolean; // default true
  suppressIntents?: ReadonlyArray<NonNullable<UpdateMetadata['intent']>>;
  suppressSources?: ReadonlyArray<NonNullable<UpdateMetadata['source']>>;
  onError?: (path: string, message: string) => void;
  formatIssue?: (issue: StandardSchemaV1.Issue, path: string) => string;
}

tree.schemas.* (after .with(schemas({...})))

| Member | Type | Purpose | |---|---|---| | errors | Signal<Record<path, string \| null>> | Path → last-settled error message (or null) | | errorList | Signal<readonly string[]> | Flat list of current error messages | | isValid | Signal<boolean> | True iff every path's last-settled verdict is valid. O(1) per read. | | pending | Signal<boolean> | True iff any path has an in-flight async run | | pendingPaths | Signal<readonly string[]> | Paths with in-flight async runs | | errorsAt(path) | Signal<string \| null> | Memoized per-path error signal | | isValidAt(path) | Signal<boolean> | Memoized per-path validity | | isPendingAt(path) | Signal<boolean> | Memoized per-path pending state | | validate() | Promise<boolean> | Re-run all schemas, resolve to current isValid() | | validatePath(path) | Promise<boolean> | Re-run schemas for one path | | compact() | void | Manual GC — evict bound paths that no longer resolve | | boundPaths | Signal<readonly string[]> | All currently-bound leaf paths (reactive) |

Entity collections — register at fields, not the collection root

Use wildcard schemas (users.*.email) to validate individual entity fields. Do NOT register a schema at the collection root itself (users):

// ✅ CORRECT — wildcard schemas validate each entity's fields
schemas({
  schemas: {
    'users.*.email': z.string().email(),
    'users.*.age': z.number().int().min(0),
  },
});

// ❌ AVOID — registering at the collection root receives the entityMap's
// full marker value (an object with `all`/`ids`/`entities` internals),
// not an array of users. Your Zod array schema will fail.
schemas({
  schemas: {
    users: z.array(userSchema),  // gets the entityMap value, not the user array
  },
});

Entity collections (markers like entityMap()) are normalized state, not arrays. They're meant to be queried via .all(), .byId(id), .where(...). Individual fields within entities are the validation surface.

Wildcard paths

Use * segments to match entity collections:

schemas({
  schemas: {
    'user.email': z.string().email(),         // specific leaf
    'users.*.email': z.string().email(),      // wildcard — every users entity
    'orders.*.items.*.qty': z.number().int(), // nested wildcards
    'profile': profileSchema,                 // ancestor schema (whole subtree)
  },
});

Precedence (D4 in the architecture plan):

  • Specific > wildcard > ancestor. A schema at users.42.email always wins over users.*.email, which always wins over a users ancestor.
  • Each leaf has exactly one owner. The owner is chosen at first-match time and cached.

Ancestor schemas

A schema registered at a non-leaf path (e.g., user) validates the whole subtree at that path. The schema runs against a fresh snapshot every time a covered leaf is written. Issues are distributed to the leaves they reference via issue.path.

schemas({
  schemas: {
    user: z.object({
      email: z.string().email(),
      age: z.number().int().min(0),
    }),
  },
});

tree.$.user.email.set('bad');
tree.schemas.errorsAt('user.email')(); // 'Invalid email'
tree.schemas.errorsAt('user.age')();   // depends on current age value

Issues from ancestor schemas use the leaf's nearest-match path via issueToLeafPath. The per-leaf staleness guard ensures slow ancestor runs can't clobber faster leaf writes that happened mid-flight.

Async semantics

Async schemas (Valibot, custom uniqueness checks, etc.) return Promises. Behavior:

  • Pending state: while a schema is in flight, pending() is true, pendingPaths() includes the path, isPendingAt(path)() is true.
  • Last-settled verdict: errorsAt, isValid, etc. read the last settled verdict — they don't flicker to null during in-flight runs.
  • Write-sequence guard: if a newer write happens while an older schema run is in flight, the older verdict is dropped on settle (orphaned). The promise still resolves to completion (we can't abort it); only its verdict is discarded.

Debounce validate() for I/O-bound schemas

validate() called repeatedly during typing piles up orphaned network requests that all run to completion before being discarded. If your schemas hit a server, debounce the caller:

const debouncedValidate = debounce(() => tree.schemas.validate(), 300);

Suppression — skip validation for replays

By default, validation runs on every write — including time-travel replays, hydration, and migrations. To suppress for specific intents/sources:

schemas({
  schemas: { ... },
  suppressIntents: ['hydrate', 'migration'],
  suppressSources: ['time-travel'],
});

The suppression reads the ambient write-context set via withWriteContext() from @signaltree/core. Devtools time-travel and the time-travel enhancer already wrap their replays in withWriteContext({ source: 'time-travel' }).

Do not suppress source: 'serialization' — deserialize is the canonical ingest case validation exists for.

Compaction (compact())

The registry's boundPathsSet is bounded by distinct leaf paths ever written that matched a schema, not by current entity count. A long-lived users.*.email over a session that churns 10,000 user rows will retain 10,000 PathState entries.

Call tree.schemas.compact() periodically (e.g., on tab visibility change, or after entity-bulk-removal) to evict bound paths that no longer resolve in the tree.

// After removing entities:
tree.schemas.compact();

Why no reject mode

Some readers reach for mode: 'reject'. We don't offer it. Reasons:

  1. Async schemas can't gate synchronously. A Promise-returning schema means the write has already notified subscribers before the verdict arrives. "Reject" would mean silently rolling back state subscribers already saw.
  2. Sync schemas don't save it either. The enhancer observes writes via interceptLeafSignalsafter the underlying signal has updated. There's no pre-write hook.
  3. It's not a validation problem. The right place to gate input is the form layer (Signal Forms' field validators, ReactiveForms' validators). The store edge is a reporter, not a gate.

If you genuinely need to refuse a write: gate it in the form, in a guardrails rule, or in a wrapper around your write site.

Bundle size

~4.3 KB gzipped. Tree-shakable. Angular is a peer dependency.

Migration: UpdateMetadata lifted to core

In v9.3, UpdateMetadata was lifted from @signaltree/guardrails to @signaltree/core. If you imported it from guardrails, update the import:

// Before (still works as deprecated re-export)
import type { UpdateMetadata } from '@signaltree/guardrails';

// After
import type { UpdateMetadata } from '@signaltree/core';

See also