Skip to content

@studnicky/types ​

Runtime type-guard, predicate, and object helpers for @studnicky/substrate.

Install ​

bash
pnpm add @studnicky/types

Runtime 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.

ts
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 ​

Loading example…

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.

typescript
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); // false

JSON 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.

typescript
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.

typescript
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.

typescript
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.

typescript
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.

typescript
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 pathUse it when
@studnicky/types/nodeNorthstar validates and transforms runtime values in a Node catalogue, checkout, or supplier boundary.
@studnicky/types/browserNorthstar applies the same browser-safe value predicates in a reader or bookseller interface.
@studnicky/types/interfacesNorthstar types predicate and runtime-value contracts without re-aliasing the package’s canonical definitions.

Exports ​

SymbolPurposeImport path
PredicatesType guards, atomic comparators, JSON Schema draft 2020-12 predicates, and value equality/coercion helpers, unified on one static class.@studnicky/types/node
PredicateTyped runtime predicate composition for boolean algebra and record, array, and map structure.@studnicky/types/node
PredicateFunctionInterfaceContract for a runtime predicate that narrows unknown to its value type.@studnicky/types/interfaces
EmptyProduces fresh empty collection instances.@studnicky/types/node
JsonObjectNarrows values at the plain-object JSON boundary.@studnicky/types/node
JsonValueValidates and coerces recursive JSON values.@studnicky/types/node
RuntimeValueTyped runtime operand predicate and intake boundary for JSON data plus native Date, Map, and Set values.@studnicky/types/node
RuntimeValueDateInterfaceNative Date operand contract.@studnicky/types/interfaces
RuntimeValueMapInterfaceNative Map operand contract.@studnicky/types/interfaces
RuntimeValueSetInterfaceNative Set operand contract.@studnicky/types/interfaces
RuntimeValueArrayInterfaceNative array operand contract.@studnicky/types/interfaces
RuntimeValueRecordInterfaceNative plain-record operand contract.@studnicky/types/interfaces
HashDeterministic FNV-1a 32-bit hash for arbitrary in-memory values.@studnicky/types/node
StructuralHashSchema hash with metadata-key stripping.@studnicky/types/node
BaseErrorAbstract root of the error hierarchy; serializes as RFC 9457 Problem Details.@studnicky/types/node
CallerFaultPass-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
RuntimeValueErrorThrown by RuntimeValue.intake for a value outside the runtime operand contract (types.runtimeValueInvalid).@studnicky/types/node
StructuralHashInputErrorThrown by StructuralHash.of for a schema that is not finite, acyclic JSON (types.structuralHashInputInvalid).@studnicky/types/node
BaseErrorArgumentsInterfaceConstruction arguments passed to BaseError subclasses.@studnicky/types/interfaces
ProblemDetailsInterfaceRFC 9457 Problem Details object returned by BaseError.toJSON().@studnicky/types/interfaces
CauseNodeInterfaceOne node of the flattened causes chain.@studnicky/types/interfaces
ThrownValueInterfaceProjection of an arbitrary caught value into RFC 9457 members.@studnicky/types/interfaces
ThrownValueProjectionTotal, cycle-safe projection of any caught value (ThrownValueProjection.project).@studnicky/types/node
CAUSE_CHAIN_DEPTH_LIMITMaximum cause-chain depth walked or serialized.@studnicky/types/node
CAUSE_DEPTH_SENTINELDetail emitted when a cause chain exceeds the depth limit.@studnicky/types/node
PROBLEM_TYPE_BASENamespace root of every problem type URI minted by the workspace.@studnicky/types/node
PROBLEM_TYPE_THROWN_NULLISHProblem type for a thrown null or undefined.@studnicky/types/node
PROBLEM_TYPE_THROWN_STRINGProblem type for a thrown string.@studnicky/types/node
PROBLEM_TYPE_THROWN_PRIMITIVEProblem type for a thrown non-string primitive.@studnicky/types/node
PROBLEM_TYPE_THROWN_OBJECTProblem type for a thrown non-Error object.@studnicky/types/node
PROBLEM_TYPE_ERRORProblem type for a thrown native Error.@studnicky/types/node
PROBLEM_TYPE_AGGREGATE_ERRORProblem type for a thrown AggregateError.@studnicky/types/node
PROBLEM_TITLE_THROWN_NULLISHStable title paired with the thrown-nullish problem type.@studnicky/types/node
PROBLEM_TITLE_THROWN_STRINGStable title paired with the thrown-string problem type.@studnicky/types/node
PROBLEM_TITLE_THROWN_PRIMITIVEStable title paired with the thrown-primitive problem type.@studnicky/types/node
PROBLEM_TITLE_THROWN_OBJECTStable title paired with the thrown-object problem type.@studnicky/types/node
PROBLEM_TITLE_ERRORStable title paired with the native-error problem type.@studnicky/types/node
PROBLEM_TITLE_AGGREGATE_ERRORStable title paired with the aggregate-error problem type.@studnicky/types/node
TIME_ONLY_PATTERNRecognizes a time-only string before a consumer applies its own domain semantics.@studnicky/types/node

Selected Predicates static methods ​

MethodDescription
isString/isNumber/isBoolean/isFunction/isNullishGeneric-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/isPlainObjectProgressively narrower object-shape guards; see each method's doc comment for the exact exclusion each adds.
isMap/isSet/isDate/isArray/isRegExp/isURL/isErrorType guards for the common non-primitive built-ins.
isEmptyString/isEmptyPlainObject/isEmptyArray/isEmptyMap/isEmptySetEmptiness 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:

ts
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.

Source on GitHub