@studnicky/types
Runtime type-guard, predicate, and object helpers for
@studnicky/substrate.
Install
pnpm add @studnicky/typesRuntime helpers publish from both @studnicky/types/node and @studnicky/types/browser. The examples use the Node path; interfaces publish from @studnicky/types/interfaces.
Usage
Predicates is the package's single unified static class for type narrowing, value comparison, and JSON Schema-style validation. Predicate composes atomic type guards while preserving their narrowed types: use and, or, not, field, arrayItems, and mapEntries to parse an untrusted value once into a canonical structural shape. Empty produces fresh empty collection instances. JsonObject and JsonValue implement runtime JSON boundaries. RuntimeValue validates recursive operands that retain native Date, Map, and Set values.
Northstar Books boundary
Every catalogue filter, cart payload, and supplier record that reaches Northstar off the wire arrives as plain unknown — and search logic downstream shouldn't have to keep re-checking what it's holding. The example below shows the admission check that makes that promise real: Predicates.isObject/asRecordArray narrow a raw payload into a checked record array, scalar guards confirm individual fields, and Empty hands back fresh collection instances for whatever didn't survive validation. RuntimeValue covers the one legitimate exception — an internal job payload that carries native Date, Map, or Set values — while JsonValue keeps the wire boundary itself JSON-only, so nothing reaches catalogue or checkout code without being normalized exactly once.
import { Empty, JsonValue, Predicates, RuntimeValue } from '../src/index.js';
import { PredicatesAccessorsFixtures } from './fixtures/PredicatesAccessorsFixtures.js';
// ── Predicates.isObject ──────────────────────────────────────────────────────
const plainObject = Predicates.isObject({ 'a': 1 });
const arrayIsRecord = Predicates.isObject([1, 2, 3]);
const nullIsRecord = Predicates.isObject(null);
console.log('Predicates.isObject({ a: 1 }):', plainObject);
console.log('Predicates.isObject([1,2,3]):', arrayIsRecord);
console.log('Predicates.isObject(null):', nullIsRecord);
// ── Predicates.asNumber / asStringOrNull ────────────────────────────────────
const numberResult = Predicates.asNumber(3.14);
const stringOrNull = Predicates.asStringOrNull(null);
console.log('Predicates.asNumber(3.14):', numberResult);
console.log('Predicates.asStringOrNull(null):', stringOrNull);
// ── Predicates.asRecordArray ────────────────────────────────────────────────
const records = Predicates.asRecordArray(PredicatesAccessorsFixtures.mixed);
console.log('Predicates.asRecordArray([{id:1},"skip",{id:2},null]):', records);
// ── Predicates type predicates ──────────────────────────────────────────────
console.log('Predicates.isString("hello"):', Predicates.isString('hello'));
console.log('Predicates.isNumber(3.14):', Predicates.isNumber(3.14));
console.log('Predicates.isNumber(NaN):', Predicates.isNumber(Number.NaN));
console.log('Predicates.isBoolean(true):', Predicates.isBoolean(true));
console.log('Predicates.isNonNegativeInteger(0):', Predicates.isNonNegativeInteger(0));
console.log('Predicates.isPositiveInteger(0):', Predicates.isPositiveInteger(0));
// ── Structural equality and cycle detection ────────────────────────────────
console.log('Predicates.areDeeplyEqual(Map, Set):', Predicates.areDeeplyEqual(PredicatesAccessorsFixtures.runtimeValues.left, PredicatesAccessorsFixtures.runtimeValues.right));
console.log('Predicates.hasCycle(Map):', Predicates.hasCycle(PredicatesAccessorsFixtures.runtimeValues.left));
// ── Static-override subclass ────────────────────────────────────────────────
class StrictPredicates extends Predicates {
public static override isObject<T>(value: T): value is Record<string, unknown> & T {
if (super.isObject(value) && !Array.isArray(value)) {
return true;
}
return false;
}
}
const strictArray = StrictPredicates.asRecordArray([{ 'a': 1 }, 99, { 'b': 2 }]);
console.log('StrictPredicates.asRecordArray([{a:1},99,{b:2}]):', strictArray);
// ── Empty producers ─────────────────────────────────────────────────────────
const emptyString = Empty.string();
const emptyObject = Empty.object();
const emptyArray = Empty.array<number>();
const emptyMap = Empty.map<string, number>();
const emptySet = Empty.set<string>();
console.log('Empty.string():', JSON.stringify(emptyString));
console.log('Empty.object():', emptyObject);
console.log('Empty.array<number>():', emptyArray);
console.log('Empty.map<string,number>().size:', emptyMap.size);
console.log('Empty.set<string>().size:', emptySet.size);
// ── Predicates emptiness checks ─────────────────────────────────────────────
console.log('Predicates.isEmptyString(""):', Predicates.isEmptyString(''));
console.log('Predicates.isEmptyPlainObject({}):', Predicates.isEmptyPlainObject({}));
console.log('Predicates.isEmptyArray([]):', Predicates.isEmptyArray([]));
console.log('Predicates.isEmptyMap(new Map()):', Predicates.isEmptyMap(new Map()));
console.log('Predicates.isEmptySet(new Set()):', Predicates.isEmptySet(new Set()));
// ── JSON value boundary ─────────────────────────────────────────────────────
const value = PredicatesAccessorsFixtures.value;
console.log('value:', JSON.stringify(value));
console.log('RuntimeValue.is(Date, Map, Set operand):', RuntimeValue.is(PredicatesAccessorsFixtures.runtimeValues.operand));Try it
The output shows Predicates.isObject/asRecordArray narrowing, scalar guards, the StrictPredicates static-override subclass, Empty producers, and a JSON value boundary.
Structural runtime values
Predicates.areDeeplyEqual compares primitives, Date, RegExp, arrays, Map, Set, and records recursively. It recognizes NaN, preserves array order, ignores Map and Set insertion order, and compares cyclic graph topology safely. Predicates.hasCycle traverses records, arrays, Map keys and values, and Set members.
import { Predicates } from "@studnicky/types/node";
const left = new Map([[{ id: 1 }, new Set([{ enabled: true }])]]);
const right = new Map([[{ id: 1 }, new Set([{ enabled: true }])]]);
Predicates.areDeeplyEqual(left, right); // true
Predicates.hasCycle(left); // falseJSON runtime boundaries
JsonObject
JsonObject.is performs a shallow plain-object check and narrows unknown to Record<string, unknown>. It rejects arrays, Map, Set, class instances, and other non-plain objects.
import { JsonObject } from "@studnicky/types/node";
const parsed: unknown = JSON.parse(responseText);
if (JsonObject.is(parsed)) {
const id = parsed.id;
console.log(id);
}Use schema validation when object members also need structural guarantees.
JsonValue
JsonValue.is narrows unknown to the canonical JSONSchema7Type owned by json-schema. JsonValue.from recursively coerces unsupported values to null, producing a finite, acyclic JSONSchema7Type without a cast.
import type { JSONSchema7Type } from "json-schema";
import { JsonValue } from "@studnicky/types/node";
const candidate: unknown = JSON.parse(responseText);
if (JsonValue.is(candidate)) {
const value: JSONSchema7Type = candidate;
console.log(value);
}
const safe: JSONSchema7Type = JsonValue.from({
nested: [1, undefined],
});Import JSONSchema7Type directly from json-schema when a public signature or local annotation needs the type. Its declarations come from the package's direct @types/json-schema dependency. @studnicky/types exports the runtime boundary, not a type alias for the dependency-owned JSON type.
Runtime values
RuntimeValue
RuntimeValue validates a runtime operand that combines JSON data and undefined with native Date, Map, Set, array, and plain-record values. RuntimeValue.is is a typed predicate for composition with Predicate. RuntimeValue.intake returns the same value after validation and throws RuntimeValueError (types.runtimeValueInvalid) for unsupported values.
Map keys and values both follow the contract without conversion. The boundary rejects functions, symbols, bigints, non-finite numbers, non-plain class instances, invalid nested values, and cycles. Use JsonValue when the boundary is JSON-only.
import { RuntimeValue } from "@studnicky/types/node";
const candidate: unknown = new Map([[{ id: 1 }, new Set([new Date(0), undefined])]]);
if (RuntimeValue.is(candidate)) {
const operand = RuntimeValue.intake(candidate);
console.log(operand);
}BaseError
BaseError is the abstract root of every error class in the workspace. It lives in @studnicky/types, the dependency-free root package, so @studnicky/entity and @studnicky/types themselves throw BaseError subclasses. @studnicky/errors builds its hierarchy (ModuleError, RuntimeError, ValidationError, ...) on top of it.
A subclass declares its name as a literal member and passes BaseErrorArgumentsInterface to super. Every instance carries code, metadata, timestamp, correlationId, retryable, status, and instance. BaseError.findCauseOfType, hasCauseOfType, getCauseChain, and toMessage walk or describe cause chains, bounded at CAUSE_CHAIN_DEPTH_LIMIT hops.
toJSON() returns an RFC 9457 Problem Details object typed as ProblemDetailsInterface, so JSON.stringify(error) produces it too. The flattened causes extension holds CauseNodeInterface nodes, nearest first. ThrownValueProjection.project is the total, never-throwing, cycle-safe projection of any caught value (an Error, AggregateError, string, primitive, null, or foreign object) into ThrownValueInterface; the PROBLEM_TYPE_* and PROBLEM_TITLE_* constants name the problem types it mints.
import { BaseError } from "@studnicky/types/node";
class PaymentDeclinedError extends BaseError {
public override readonly name: string = "PaymentDeclinedError";
public constructor(reason: string, cause?: unknown) {
super({ cause: cause, code: "payments.declined", message: reason, status: 402 });
}
}CallerFault
CallerFault.propagate(value) throws value unchanged, and CallerFault.rejection(value) returns a promise rejected with value unchanged. It is the sanctioned channel for an error raised by caller-supplied code (a task, hook, or reducer the consumer passes in), which reaches the caller as thrown. Library-originated and platform errors never pass through it: they are named BaseError subclasses, and a platform error is wrapped with the original as cause. The @studnicky/no-native-error rule exempts the bodies of CallerFault.propagate and CallerFault.rejection and reports every other throw, executor reject, PromiseWithResolvers reject, and Promise.reject of a non-BaseError value. CallerFault.propagate returns never, so it is written as a bare statement.
import { CallerFault } from "@studnicky/types/node";
export async function run(task: () => Promise<string>): Promise<string> {
try {
return await task();
} catch (error: unknown) {
CallerFault.propagate(error);
}
}What it is
@studnicky/types is a collection of runtime narrowing, boundary-validation, JSON, empty-value, equality, hashing, and error-foundation primitives. It turns an unknown value into a checked value at a boundary; it does not define Northstar’s catalogue schema, request policy, or application model.
What it is for
Northstar Books uses these primitives at the intake edge of catalogue filters, carts, and supplier records so domain code receives one normalized value instead of repeatedly reinterpreting unknown fields. Node and browser are runtime alternatives, and the interfaces entrypoint exposes contracts for consumers that need the package’s structural types without creating a second domain type system.
Northstar Books examples
- Predicates accessors, type predicates, and Empty producers solves the “accept only a usable catalogue-filter payload before search logic sees it” problem. It narrows unknown records, checks scalar values, produces new empty collections, and applies a JSON boundary, proving that Northstar can normalize input once at admission.
Public entrypoints
| Import path | Use it when |
|---|---|
@studnicky/types/node | Northstar validates and transforms runtime values in a Node catalogue, checkout, or supplier boundary. |
@studnicky/types/browser | Northstar applies the same browser-safe value predicates in a reader or bookseller interface. |
@studnicky/types/interfaces | Northstar types predicate and runtime-value contracts without re-aliasing the package’s canonical definitions. |
Exports
| Symbol | Purpose | Import path |
|---|---|---|
Predicates | Type guards, atomic comparators, JSON Schema draft 2020-12 predicates, and value equality/coercion helpers, unified on one static class. | @studnicky/types/node |
Predicate | Typed runtime predicate composition for boolean algebra and record, array, and map structure. | @studnicky/types/node |
PredicateFunctionInterface | Contract for a runtime predicate that narrows unknown to its value type. | @studnicky/types/interfaces |
Empty | Produces fresh empty collection instances. | @studnicky/types/node |
JsonObject | Narrows values at the plain-object JSON boundary. | @studnicky/types/node |
JsonValue | Validates and coerces recursive JSON values. | @studnicky/types/node |
RuntimeValue | Typed runtime operand predicate and intake boundary for JSON data plus native Date, Map, and Set values. | @studnicky/types/node |
RuntimeValueDateInterface | Native Date operand contract. | @studnicky/types/interfaces |
RuntimeValueMapInterface | Native Map operand contract. | @studnicky/types/interfaces |
RuntimeValueSetInterface | Native Set operand contract. | @studnicky/types/interfaces |
RuntimeValueArrayInterface | Native array operand contract. | @studnicky/types/interfaces |
RuntimeValueRecordInterface | Native plain-record operand contract. | @studnicky/types/interfaces |
Hash | Deterministic FNV-1a 32-bit hash for arbitrary in-memory values. | @studnicky/types/node |
StructuralHash | Schema hash with metadata-key stripping. | @studnicky/types/node |
BaseError | Abstract root of the error hierarchy; serializes as RFC 9457 Problem Details. | @studnicky/types/node |
CallerFault | Pass-through channel that rethrows an error raised by caller-supplied code unchanged (CallerFault.propagate) or rejects a promise with it (CallerFault.rejection). | @studnicky/types/node |
RuntimeValueError | Thrown by RuntimeValue.intake for a value outside the runtime operand contract (types.runtimeValueInvalid). | @studnicky/types/node |
StructuralHashInputError | Thrown by StructuralHash.of for a schema that is not finite, acyclic JSON (types.structuralHashInputInvalid). | @studnicky/types/node |
BaseErrorArgumentsInterface | Construction arguments passed to BaseError subclasses. | @studnicky/types/interfaces |
ProblemDetailsInterface | RFC 9457 Problem Details object returned by BaseError.toJSON(). | @studnicky/types/interfaces |
CauseNodeInterface | One node of the flattened causes chain. | @studnicky/types/interfaces |
ThrownValueInterface | Projection of an arbitrary caught value into RFC 9457 members. | @studnicky/types/interfaces |
ThrownValueProjection | Total, cycle-safe projection of any caught value (ThrownValueProjection.project). | @studnicky/types/node |
CAUSE_CHAIN_DEPTH_LIMIT | Maximum cause-chain depth walked or serialized. | @studnicky/types/node |
CAUSE_DEPTH_SENTINEL | Detail emitted when a cause chain exceeds the depth limit. | @studnicky/types/node |
PROBLEM_TYPE_BASE | Namespace root of every problem type URI minted by the workspace. | @studnicky/types/node |
PROBLEM_TYPE_THROWN_NULLISH | Problem type for a thrown null or undefined. | @studnicky/types/node |
PROBLEM_TYPE_THROWN_STRING | Problem type for a thrown string. | @studnicky/types/node |
PROBLEM_TYPE_THROWN_PRIMITIVE | Problem type for a thrown non-string primitive. | @studnicky/types/node |
PROBLEM_TYPE_THROWN_OBJECT | Problem type for a thrown non-Error object. | @studnicky/types/node |
PROBLEM_TYPE_ERROR | Problem type for a thrown native Error. | @studnicky/types/node |
PROBLEM_TYPE_AGGREGATE_ERROR | Problem type for a thrown AggregateError. | @studnicky/types/node |
PROBLEM_TITLE_THROWN_NULLISH | Stable title paired with the thrown-nullish problem type. | @studnicky/types/node |
PROBLEM_TITLE_THROWN_STRING | Stable title paired with the thrown-string problem type. | @studnicky/types/node |
PROBLEM_TITLE_THROWN_PRIMITIVE | Stable title paired with the thrown-primitive problem type. | @studnicky/types/node |
PROBLEM_TITLE_THROWN_OBJECT | Stable title paired with the thrown-object problem type. | @studnicky/types/node |
PROBLEM_TITLE_ERROR | Stable title paired with the native-error problem type. | @studnicky/types/node |
PROBLEM_TITLE_AGGREGATE_ERROR | Stable title paired with the aggregate-error problem type. | @studnicky/types/node |
TIME_ONLY_PATTERN | Recognizes a time-only string before a consumer applies its own domain semantics. | @studnicky/types/node |
Selected Predicates static methods
| Method | Description |
|---|---|
isString/isNumber/isBoolean/isFunction/isNullish | Generic-preserving type guards (<T>(value: T): value is X & T) — narrow an already-typed value without discarding its declared shape. |
isNumberType(value) | typeof value === 'number', including NaN/Infinity — use over isNumber when the caller routes those values to a more specific downstream check. |
isObjectLike/isObject/isRecord/isPlainObject | Progressively narrower object-shape guards; see each method's doc comment for the exact exclusion each adds. |
isMap/isSet/isDate/isArray/isRegExp/isURL/isError | Type guards for the common non-primitive built-ins. |
isEmptyString/isEmptyPlainObject/isEmptyArray/isEmptyMap/isEmptySet | Emptiness checks — pair with Empty's producers of the same five shapes. |
areDeeplyEqual(value, other) | Cycle-safe structural equality for primitive values, Date, RegExp, arrays, Map, Set, and records. |
hasCycle(value) | Detects cycles through records, arrays, Map keys and values, and Set members. |
isFiniteNumber(value) | True for finite number values. |
isIntegerValue(value) | True for integer number values. |
inferValueType(value) | Returns JSON Schema type name ('null', 'array', 'object', etc.) |
matchesType(schemaType, value) | True if value satisfies the named JSON Schema type. |
satisfiesUniqueItems(arr) | Deep-equal uniqueness check. |
satisfiesContentEncoding(value, encoding) | Validates base64/base64url encoding. |
satisfiesContentMediaType(value, mediaType, encoding?) | Validates application/json content. |
Hashing (Hash and StructuralHash)
Northstar wants to know whether two JSON schema documents describe the same shape, even when one carries a $id and a human-written title that the other doesn't — those are annotations, not structure. Hash.value gives any in-memory value, Date, Map, and Set included, a deterministic FNV-1a digest, and StructuralHash.of builds on it by stripping annotation-only keys before hashing, so a schema with full metadata and its bare structural twin come out identical below, while two schemas that actually differ in type still hash apart:
import { Hash, StructuralHash } from '../src/index.js';
import { HashFixture } from './fixtures/HashFixture.js';
// ---------------------------------------------------------------------------
// Hash.value — deterministic FNV-1a 32-bit hex
// ---------------------------------------------------------------------------
const firstHash = Hash.value({ 'a': 1, 'b': 2 });
const secondHash = Hash.value({ 'a': 1, 'b': 2 });
console.log('firstHash:', firstHash, 'secondHash:', secondHash, 'equal:', firstHash === secondHash);
// ---------------------------------------------------------------------------
// StructuralHash.of — strips annotation-only keys before hashing
// ---------------------------------------------------------------------------
const schemaWithMeta = HashFixture.SchemaWithMeta;
const schemaBare = HashFixture.SchemaBare;
console.log('StructuralHash with meta:', StructuralHash.of(schemaWithMeta));
console.log('StructuralHash bare:', StructuralHash.of(schemaBare));
console.log('equal (annotations stripped):', StructuralHash.of(schemaWithMeta) === StructuralHash.of(schemaBare));Extending
Predicates is a pure-static class. Extend it and override a static method — most commonly isObject, to customise record detection — and other methods that delegate through this.<method> (e.g. asRecordArray delegates through this.isObject) will propagate the override automatically.