@studnicky/types
Shared zero-runtime utility types and type-guard helpers for
@studnicky/substrate.
Install
pnpm add @studnicky/typesUsage
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:
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-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');
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:
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:
import { JsonValue } from '@studnicky/types';
const raw: unknown = await fetchApiResponse();
const safe = JsonValue.from(raw); // JsonValueType, never a lieUse 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:
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)
/** 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');
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
| Subpath | Contents |
|---|---|
@studnicky/types | All types + Guard + Empty + JsonObject + JsonValue + PickDefined |
@studnicky/types/types | JsonValueType, JsonObjectType, DeepReadonlyType, DeepMergeType, JsonSchemaType, JsonSchemaObjectType, JsonSchemaTypeNameType |
@studnicky/types/guards | Guard, 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.