Skip to content

@studnicky/types

Shared zero-runtime utility types and type-guard helpers for @studnicky/substrate.

Install

bash
pnpm add @studnicky/types

Usage

Guard provides type-safe narrowing accessors for wire-format values. Empty produces fresh empty collection instances and predicates. Type utilities (JsonValueType, DeepReadonlyType, DeepMergeType) are erased at compile time and carry no runtime cost:

ts
import { Empty, Guard } from '../src/index.js';
import { GuardAccessorsFixtures } from './fixtures/GuardAccessorsFixtures.js';

// ── Guard.isObject ───────────────────────────────────────────────────────────

const plainObj = Guard.isObject({ 'a': 1 });
const arrIsRecord = Guard.isObject([1, 2, 3]);
const nullIsRecord = Guard.isObject(null);

console.log('Guard.isObject({ a: 1 }):', plainObj);
console.log('Guard.isObject([1,2,3]):', arrIsRecord);
console.log('Guard.isObject(null):', nullIsRecord);

// ── Guard.asRecord ──────────────────────────────────────────────────────────

const rec = Guard.asRecord({ 'age': 42, 'name': 'Ada' });
const recFromNull = Guard.asRecord(null);

console.log('Guard.asRecord({ age:42, name:"Ada" }):', rec);
console.log('Guard.asRecord(null):', recFromNull);

// ── Guard.asString / asNumber / asStringOrNull ──────────────────────────────

const str = Guard.asString('hello');
const numResult = Guard.asNumber(3.14);
const strOrNull = Guard.asStringOrNull(null);

console.log('Guard.asString("hello"):', str);
console.log('Guard.asNumber(3.14):', numResult);
console.log('Guard.asStringOrNull(null):', strOrNull);

// ── Guard.asRecordArray ─────────────────────────────────────────────────────

const records = Guard.asRecordArray(GuardAccessorsFixtures.mixed);

console.log('Guard.asRecordArray([{id:1},"skip",{id:2},null]):', records);

// ── Guard type predicates ───────────────────────────────────────────────────

console.log('Guard.isString("hello"):', Guard.isString('hello'));
console.log('Guard.isNumber(3.14):', Guard.isNumber(3.14));
console.log('Guard.isNumber(NaN):', Guard.isNumber(Number.NaN));
console.log('Guard.isBoolean(true):', Guard.isBoolean(true));
console.log('Guard.isNonNegativeInteger(0):', Guard.isNonNegativeInteger(0));
console.log('Guard.isPositiveInteger(0):', Guard.isPositiveInteger(0));

// ── Static-override subclass ────────────────────────────────────────────────

class StrictGuard extends Guard {
  public static override isObject(value: unknown): value is Record<string, unknown> {
    return super.isObject(value) && !Array.isArray(value);
  }
}

const strictRec = StrictGuard.asRecord({ 'host': 'localhost' });
const strictArr = StrictGuard.asRecordArray([{ 'a': 1 }, 99, { 'b': 2 }]);

console.log('StrictGuard.asRecord({ host:"localhost" }):', strictRec);
console.log('StrictGuard.asRecordArray([{a:1},99,{b:2}]):', strictArr);

// ── Empty producers ─────────────────────────────────────────────────────────

const emptyStr = Empty.string();
const emptyObj = Empty.object();
const emptyArr = Empty.array<number>();
const emptyMap = Empty.map<string, number>();
const emptySet = Empty.set<string>();

console.log('Empty.string():', JSON.stringify(emptyStr));
console.log('Empty.object():', emptyObj);
console.log('Empty.array<number>():', emptyArr);
console.log('Empty.map<string,number>().size:', emptyMap.size);
console.log('Empty.set<string>().size:', emptySet.size);

// ── Empty predicates ────────────────────────────────────────────────────────

console.log('Empty.isString(""):', Empty.isString(''));
console.log('Empty.isObject({}):', Empty.isObject({}));
console.log('Empty.isArray([]):', Empty.isArray([]));
console.log('Empty.isMap(new Map()):', Empty.isMap(new Map()));
console.log('Empty.isSet(new Set()):', Empty.isSet(new Set()));

// ── Type-level witnesses ────────────────────────────────────────────────────
// Values typed as JsonSchemaObjectType / JsonValueType prove the utility at compile time.

const schema = GuardAccessorsFixtures.schema;
const value = GuardAccessorsFixtures.value;

console.log('schema.type:', schema.type);
console.log('value:', JSON.stringify(value));

Try it

Guard accessors, type predicates, and Empty producers
/** guard-accessors — type-safe Guard accessors and Empty producers. Run: npx tsx packages/types/examples/guard-accessors.ts */

import assert from 'node:assert/strict';

// #region usage
import { Empty, Guard } from '../src/index.js';
import { GuardAccessorsFixtures } from './fixtures/GuardAccessorsFixtures.js';

// ── Guard.isObject ───────────────────────────────────────────────────────────

const plainObj = Guard.isObject({ 'a': 1 });
const arrIsRecord = Guard.isObject([1, 2, 3]);
const nullIsRecord = Guard.isObject(null);

console.log('Guard.isObject({ a: 1 }):', plainObj);
console.log('Guard.isObject([1,2,3]):', arrIsRecord);
console.log('Guard.isObject(null):', nullIsRecord);

// ── Guard.asRecord ──────────────────────────────────────────────────────────

const rec = Guard.asRecord({ 'age': 42, 'name': 'Ada' });
const recFromNull = Guard.asRecord(null);

console.log('Guard.asRecord({ age:42, name:"Ada" }):', rec);
console.log('Guard.asRecord(null):', recFromNull);

// ── Guard.asString / asNumber / asStringOrNull ──────────────────────────────

const str = Guard.asString('hello');
const numResult = Guard.asNumber(3.14);
const strOrNull = Guard.asStringOrNull(null);

console.log('Guard.asString("hello"):', str);
console.log('Guard.asNumber(3.14):', numResult);
console.log('Guard.asStringOrNull(null):', strOrNull);

// ── Guard.asRecordArray ─────────────────────────────────────────────────────

const records = Guard.asRecordArray(GuardAccessorsFixtures.mixed);

console.log('Guard.asRecordArray([{id:1},"skip",{id:2},null]):', records);

// ── Guard type predicates ───────────────────────────────────────────────────

console.log('Guard.isString("hello"):', Guard.isString('hello'));
console.log('Guard.isNumber(3.14):', Guard.isNumber(3.14));
console.log('Guard.isNumber(NaN):', Guard.isNumber(Number.NaN));
console.log('Guard.isBoolean(true):', Guard.isBoolean(true));
console.log('Guard.isNonNegativeInteger(0):', Guard.isNonNegativeInteger(0));
console.log('Guard.isPositiveInteger(0):', Guard.isPositiveInteger(0));

// ── Static-override subclass ────────────────────────────────────────────────

class StrictGuard extends Guard {
  public static override isObject(value: unknown): value is Record<string, unknown> {
    return super.isObject(value) && !Array.isArray(value);
  }
}

const strictRec = StrictGuard.asRecord({ 'host': 'localhost' });
const strictArr = StrictGuard.asRecordArray([{ 'a': 1 }, 99, { 'b': 2 }]);

console.log('StrictGuard.asRecord({ host:"localhost" }):', strictRec);
console.log('StrictGuard.asRecordArray([{a:1},99,{b:2}]):', strictArr);

// ── Empty producers ─────────────────────────────────────────────────────────

const emptyStr = Empty.string();
const emptyObj = Empty.object();
const emptyArr = Empty.array<number>();
const emptyMap = Empty.map<string, number>();
const emptySet = Empty.set<string>();

console.log('Empty.string():', JSON.stringify(emptyStr));
console.log('Empty.object():', emptyObj);
console.log('Empty.array<number>():', emptyArr);
console.log('Empty.map<string,number>().size:', emptyMap.size);
console.log('Empty.set<string>().size:', emptySet.size);

// ── Empty predicates ────────────────────────────────────────────────────────

console.log('Empty.isString(""):', Empty.isString(''));
console.log('Empty.isObject({}):', Empty.isObject({}));
console.log('Empty.isArray([]):', Empty.isArray([]));
console.log('Empty.isMap(new Map()):', Empty.isMap(new Map()));
console.log('Empty.isSet(new Set()):', Empty.isSet(new Set()));

// ── Type-level witnesses ────────────────────────────────────────────────────
// Values typed as JsonSchemaObjectType / JsonValueType prove the utility at compile time.

const schema = GuardAccessorsFixtures.schema;
const value = GuardAccessorsFixtures.value;

console.log('schema.type:', schema.type);
console.log('value:', JSON.stringify(value));
// #endregion usage

// Guard assertions
assert.equal(plainObj, true, 'plain object is a record');
assert.equal(arrIsRecord, false, 'array is not a record');
assert.equal(nullIsRecord, false, 'null is not a record');
assert.equal(Guard.isObject('hello'), false, 'string is not a record');

assert.ok(rec !== undefined, 'asRecord returns the object');
assert.equal(rec?.name, 'Ada');
assert.equal(recFromNull, undefined, 'asRecord returns undefined for null');
assert.equal(Guard.asRecord([]), undefined, 'asRecord returns undefined for array');

assert.equal(str, 'hello');
assert.equal(Guard.asString(42), undefined, 'number is not a string');
assert.equal(Guard.asString(null), undefined, 'null is not a string');
assert.equal(numResult, 3.14);
assert.equal(Guard.asNumber('3'), undefined, 'string is not a number');
assert.equal(Guard.asNumber(Number.NaN), Number.NaN, 'NaN passes typeof check');
assert.equal(strOrNull, null, 'null returns null');
assert.equal(Guard.asStringOrNull('hello'), 'hello');
assert.equal(Guard.asStringOrNull(42), undefined, 'number returns undefined');

assert.ok(records !== undefined);
assert.equal(records?.length, 2, 'non-record elements are filtered out');
assert.equal(records?.[0]?.id, 1);
assert.equal(records?.[1]?.id, 2);
assert.equal(Guard.asRecordArray('not-an-array'), undefined);
assert.equal(Guard.asRecordArray(['a', 'b']), undefined, 'all-string array returns undefined');

assert.equal(Guard.isString('hello'), true);
assert.equal(Guard.isString(42), false);
assert.equal(Guard.isNumber(3.14), true);
assert.equal(Guard.isNumber(Number.NaN), false, 'NaN is not a valid number');
assert.equal(Guard.isBoolean(true), true);
assert.equal(Guard.isBoolean(1), false);
assert.equal(Guard.isFunction(JSON.stringify), true);
assert.equal(Guard.isFunction('fn'), false);
assert.equal(Guard.isNonNegativeInteger(0), true);
assert.equal(Guard.isNonNegativeInteger(5), true);
assert.equal(Guard.isNonNegativeInteger(-1), false);
assert.equal(Guard.isPositiveInteger(1), true);
assert.equal(Guard.isPositiveInteger(0), false);

assert.equal(StrictGuard.isObject({ 'x': 1 }), true, 'StrictGuard accepts plain objects');
assert.equal(StrictGuard.isObject([]), false, 'StrictGuard rejects arrays');
assert.ok(strictRec !== undefined);
assert.equal(strictRec?.host, 'localhost');
assert.ok(strictArr !== undefined);
assert.equal(strictArr?.length, 2);

assert.equal(emptyStr, '', 'Empty.string() returns empty string');
assert.deepEqual(emptyObj, {}, 'Empty.object() returns empty object');
assert.deepEqual(emptyArr, [], 'Empty.array() returns empty array');
assert.equal(emptyMap.size, 0, 'Empty.map() returns empty map');
assert.equal(emptySet.size, 0, 'Empty.set() returns empty set');

assert.equal(Empty.isString(''), true);
assert.equal(Empty.isString('x'), false);
assert.equal(Empty.isObject({}), true);
assert.equal(Empty.isObject({ 'a': 1 }), false);
assert.equal(Empty.isArray([]), true);
assert.equal(Empty.isArray([1]), false);
assert.equal(Empty.isMap(new Map()), true);
assert.equal(Empty.isSet(new Set()), true);

assert.equal(schema.type, 'string', 'JsonSchemaObjectType accepts schema keyword object');
assert.deepEqual(value, { 'nested': [1, 'two', null] }, 'JsonValueType accepts nested JSON');

console.log('guard-accessors: all assertions passed');
Output
Press Execute to run this example against the real library.

The output shows Guard.isRecord/asRecord/asRecordArray narrowing, scalar accessors, the StrictGuard static-override subclass, and Empty producing and testing fresh zero-value collection instances.

JSON runtime guards (JsonObject, JsonValue)

JsonObjectType and JsonValueType are compile-time-only annotations — they narrow nothing at runtime. JsonObject and JsonValue are the runtime counterparts: pure-static guard classes that actually inspect a value and narrow or coerce it, for the boundary where a payload is genuinely unknown (a deserialized blob, a generic tool return, JSON.parse output).

JsonObject.is is a type-guard predicate narrowing unknown to JsonObjectType:

typescript
import { JsonObject } from '@studnicky/types';

const parsed: unknown = JSON.parse(responseText);

if (JsonObject.is(parsed)) {
  // parsed is JsonObjectType (Record<string, unknown>) here
}

JsonValue.from is cast-free coercion: rather than asserting value as JsonValueType — a lie when the value is a function, undefined, symbol, or bigint — it walks the value and returns a real JsonValueType. Primitives pass through, arrays and plain objects recurse field-wise, and anything not representable in JSON becomes null:

typescript
import { JsonValue } from '@studnicky/types';

const raw: unknown = await fetchApiResponse();
const safe = JsonValue.from(raw); // JsonValueType, never a lie

Use the Type-suffixed exports (JsonObjectType, JsonValueType) to annotate a value you already trust. Use JsonObject/JsonValue to actually narrow or coerce a value you don't yet trust.

Assembling options objects (PickDefined)

PickDefined.from strips undefined-valued keys from a record, narrowing each remaining value's type away from undefined. It's built for builders that assemble an options object from a mix of required and optional fields, replacing a manual spread-ternary chain with one call:

ts
import { PickDefined } from '../src/index.js';

// A function-typed member ('clock') is a genuine contract signal, not a pure
// data shape — matches the TokenBucketOptionsInterface precedent.
interface RateLimiterOptionsInterface {
  'burstSize': number;
  'clock'?: () => number;
  'deadlineMs'?: number;
  'requestsPerSecond': number;
}

class RateLimiterBuilder {
  private requestsPerSecond?: number;
  private burstSize?: number;
  private clock?: () => number;
  private deadlineMs?: number;

  public withRequestsPerSecond(value: number): this {
    this.requestsPerSecond = value;
    return this;
  }

  public withBurstSize(value: number): this {
    this.burstSize = value;
    return this;
  }

  public withClock(value: () => number): this {
    this.clock = value;
    return this;
  }

  public build(): RateLimiterOptionsInterface {
    const result = PickDefined.from({
      'burstSize': this.burstSize ?? 20,
      'clock': this.clock,
      'deadlineMs': this.deadlineMs,
      'requestsPerSecond': this.requestsPerSecond ?? 10
    });
    return result;
  }
}

const withClock = new RateLimiterBuilder()
  .withRequestsPerSecond(5)
  .withBurstSize(15)
  .withClock(() => { const result = Date.now(); return result; })
  .build();

const withoutClock = new RateLimiterBuilder().build();

console.log('withClock:', { ...withClock, 'clock': typeof withClock.clock });
console.log('withoutClock:', withoutClock);

Try it (PickDefined)

Assembling a builder's options object with PickDefined
/** pickDefined — assembling an options object from required and optional builder fields. Run: npx tsx packages/types/examples/pickDefined.ts */

import assert from 'node:assert/strict';

// #region usage
import { PickDefined } from '../src/index.js';

// A function-typed member ('clock') is a genuine contract signal, not a pure
// data shape — matches the TokenBucketOptionsInterface precedent.
interface RateLimiterOptionsInterface {
  'burstSize': number;
  'clock'?: () => number;
  'deadlineMs'?: number;
  'requestsPerSecond': number;
}

class RateLimiterBuilder {
  private requestsPerSecond?: number;
  private burstSize?: number;
  private clock?: () => number;
  private deadlineMs?: number;

  public withRequestsPerSecond(value: number): this {
    this.requestsPerSecond = value;
    return this;
  }

  public withBurstSize(value: number): this {
    this.burstSize = value;
    return this;
  }

  public withClock(value: () => number): this {
    this.clock = value;
    return this;
  }

  public build(): RateLimiterOptionsInterface {
    const result = PickDefined.from({
      'burstSize': this.burstSize ?? 20,
      'clock': this.clock,
      'deadlineMs': this.deadlineMs,
      'requestsPerSecond': this.requestsPerSecond ?? 10
    });
    return result;
  }
}

const withClock = new RateLimiterBuilder()
  .withRequestsPerSecond(5)
  .withBurstSize(15)
  .withClock(() => { const result = Date.now(); return result; })
  .build();

const withoutClock = new RateLimiterBuilder().build();

console.log('withClock:', { ...withClock, 'clock': typeof withClock.clock });
console.log('withoutClock:', withoutClock);
// #endregion usage

assert.equal(withClock.requestsPerSecond, 5);
assert.equal(withClock.burstSize, 15);
assert.equal(typeof withClock.clock, 'function');
assert.equal('deadlineMs' in withClock, false, 'unset optional field is stripped');

assert.deepEqual(withoutClock, { 'burstSize': 20, 'requestsPerSecond': 10 });
assert.equal('clock' in withoutClock, false, 'unset optional field is stripped');
assert.equal('deadlineMs' in withoutClock, false, 'unset optional field is stripped');

console.log('pickDefined: all assertions passed');
Output
Press Execute to run this example against the real library.

The output shows a builder that populates required fields with defaults and an optional clock field only when it was set — PickDefined.from drops the unset optional keys instead of carrying them through as undefined.

Subpath exports

SubpathContents
@studnicky/typesAll types + Guard + Empty + JsonObject + JsonValue + PickDefined
@studnicky/types/typesJsonValueType, JsonObjectType, DeepReadonlyType, DeepMergeType, JsonSchemaType, JsonSchemaObjectType, JsonSchemaTypeNameType
@studnicky/types/guardsGuard, Empty, JsonObject, JsonValue

Extending

Guard is a pure-static class. Extend it and static override isRecord to customise record detection; asRecord and asRecordArray delegate through this.isRecord, so overrides propagate automatically. The subclass pattern is demonstrated in the usage example above.

Source on GitHub