Skip to content

Commit 06ffc48

Browse files
committed
Added assertObject / isRecord.
1 parent 4314d10 commit 06ffc48

File tree

3 files changed

+165
-9
lines changed

3 files changed

+165
-9
lines changed

src/functions.ts

Lines changed: 127 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,38 @@
66

77
export * from 'klona/full';
88

9+
/**
10+
* Asserts that a value is an object, not null, and not an array.
11+
*
12+
* Unlike {@link isObject}, this function does **not** narrow the value to a generic indexable structure. Instead, it
13+
* preserves the **existing** static type of the variable. This makes it ideal for validating option objects or
14+
* interface-based inputs where all properties may be optional.
15+
*
16+
* Use this function when:
17+
* ```
18+
* - You expect a value to be an object at runtime, **and**
19+
* - You want to keep its compile-time type intact after validation.
20+
* ```
21+
*
22+
* @example
23+
* interface Options { flag?: boolean; value?: number; }
24+
*
25+
* function run(opts: Options = {}) {
26+
* assertObject(opts, `'opts' is not an object.`); // `opts` remains `Options`, not widened or reduced.
27+
* opts.value; // Fully typed access remains available.
28+
* }
29+
*
30+
* @throws {TypeError} if the value is null, non-object, or an array.
31+
*
32+
* @param value - The value to validate.
33+
*
34+
* @param errorMsg - Optional message used for the thrown TypeError.
35+
*/
36+
export function assertObject(value: unknown, errorMsg: string = 'Expected an object.'): asserts value is object
37+
{
38+
if (value === null || typeof value !== 'object' || Array.isArray(value)) { throw new TypeError(errorMsg); }
39+
}
40+
941
/**
1042
* Freezes all entries traversed that are objects including entries in arrays.
1143
*
@@ -484,35 +516,121 @@ export function isIterable<T>(value: unknown): value is Iterable<T>
484516
return value !== null && typeof value === 'object' && typeof (value as any)[Symbol.iterator] === 'function';
485517
}
486518

519+
export function isObject<T extends object>(value: T): value is T;
520+
487521
/**
488-
* Tests for whether object is not null, typeof object, and not an array.
522+
* Runtime check for whether a value is an object:
523+
* ```
524+
* - typeof === 'object'
525+
* - not null
526+
* - not an array
527+
* ```
489528
*
490-
* @param value - Any value.
529+
* This function performs **type narrowing**. If the check succeeds, TypeScript refines the type of `value` to `T`,
530+
* allowing known object types (interfaces, classes, mapped structures) to retain their original shape.
531+
*
532+
* Type Behavior:
533+
* - When called with a value that already has a specific object type (interface or shaped object), that type is
534+
* preserved after narrowing. Property access remains fully typed.
535+
*
536+
* - When called with `unknown`, `any`, or an untyped object literal, `T` becomes `object`, ensuring only that a
537+
* non-null object exists. No indexing or deep property inference is provided in this case.
538+
*
539+
* In other words:
540+
* ```
541+
* - Known object type → remains that type (preferred behavior)
542+
* - Unknown / untyped → narrows only to `object`
543+
* ```
491544
*
492-
* @returns Is it an object.
545+
* Use this when you want runtime object validation **and** want to preserve typing when a value is already known to be
546+
* a specific object type. If you instead need to **retain** the declared type regardless of narrowing, use
547+
* {@link assertObject}. If you need indexable key / value access use a dedicated record check such as
548+
* {@link isRecord} or {@link isPlainObject}.
549+
*
550+
* @param value - Any value to check.
551+
*
552+
* @returns True if the value is a non-null object and not an array.
493553
*/
494-
export function isObject<T extends object>(value: T | unknown): value is T
554+
export function isObject(value: unknown): value is object;
555+
export function isObject(value: unknown): value is object
495556
{
496557
return value !== null && typeof value === 'object' && !Array.isArray(value);
497558
}
498559

560+
export function isPlainObject<T extends object>(value: T): value is T;
561+
499562
/**
500-
* Tests for whether the given value is a plain object.
563+
* Determines whether a value is a **plain** object.
564+
*
565+
* A plain object is one whose prototype is either:
566+
* - `Object.prototype` (created via `{}` or `new Object()`)
567+
* - `null` (created via `Object.create(null)`)
568+
*
569+
* This excludes arrays, functions, class instances, DOM objects, and any object with a custom prototype. In other
570+
* words, this function detects JSON-like dictionary objects rather than structural or callable object types.
571+
*
572+
* Type Behavior:
573+
* - If the input already has a known object type `T`, that type is preserved after narrowing.
574+
* - If the input is `unknown` or untyped the result narrows to `Record<string, unknown>` allowing safe keyed access.
501575
*
502-
* An object is plain if it is created by either: `{}`, `new Object()` or `Object.create(null)`.
576+
* Useful when validating configuration objects, cloning or merging data, performing deep equality, or working with
577+
* structured JSON where non-plain / prototype values would be considered invalid.
503578
*
504-
* @param value - Any value
579+
* @example
580+
* const a = { x: 1 };
581+
* isPlainObject(a); // true
582+
*
583+
* class Foo {}
584+
* isPlainObject(new Foo()); // false
585+
*
586+
* @example
587+
* let data: unknown = getValue();
588+
* if (isPlainObject(data)) {
589+
* data.foo; // ok — key is `unknown`, but structure is guaranteed.
590+
* }
591+
*
592+
* @param value - Any value to evaluate.
505593
*
506-
* @returns Is it a plain object.
594+
* @returns True if the value is a plain object with no special prototype.
507595
*/
508-
export function isPlainObject<T extends object>(value: unknown): value is T
596+
export function isPlainObject(value: unknown): value is Record<string, unknown>;
597+
export function isPlainObject(value: unknown): value is Record<string, unknown>
509598
{
510599
if (Object.prototype.toString.call(value) !== '[object Object]') { return false; }
511600

512601
const prototype: any = Object.getPrototypeOf(value);
513602
return prototype === null || prototype === Object.prototype;
514603
}
515604

605+
/**
606+
* Checks whether a value is a generic key / value object / `Record<string, unknown>`.
607+
*
608+
* A record in this context means:
609+
* - `typeof value === 'object'`
610+
* - value is not `null`
611+
* - value is not an array
612+
*
613+
* Unlike {@link isObject}, this function does **not** attempt to preserve the original object type. All successful
614+
* results narrow to `Record<string, unknown>` making the returned value safe for key-indexed access but without any
615+
* knowledge of property names or expected value types.
616+
*
617+
* This is useful when processing untyped JSON-like data structures, dynamic configuration blocks, response bodies,
618+
* or any case where a dictionary-style object is expected rather than a typed interface value.
619+
*
620+
* Contrast With:
621+
* - {@link isObject} → preserves known object types where possible; use when typing should remain intact.
622+
* - {@link isPlainObject} → narrows to plain JSON objects only (no prototypes, no class instances).
623+
* - `isRecord()` → always narrows to a dictionary-style record for keyed lookup.
624+
*
625+
* @param value - Any value to test.
626+
*
627+
* @returns True if the value is an object that is neither null nor an array.
628+
*/
629+
export function isRecord(value: unknown): value is Record<string, unknown>
630+
{
631+
return value !== null && typeof value === 'object' && !Array.isArray(value);
632+
}
633+
516634
/**
517635
* Safely returns keys on an object or an empty array if not an object.
518636
*

src/plugin.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import * as OU from './functions.js';
1111
*
1212
* Wires up object util functions on the plugin eventbus. The following event bindings are available:
1313
* ```
14+
* - `typhonjs:utils:object:assert`: Invokes `assertObject`.
1415
* - `typhonjs:utils:object:deep:freeze`: Invokes `deepFreeze`.
1516
* - `typhonjs:utils:object:deep:merge`: Invokes `deepMerge`.
1617
* - `typhonjs:utils:object:deep:seal`: Invokes `deepSeal`.
@@ -24,6 +25,7 @@ import * as OU from './functions.js';
2425
* - `typhonjs:utils:object:is:iterable`: Invokes `isIterable`.
2526
* - `typhonjs:utils:object:is:object`: Invokes `isObject`.
2627
* - `typhonjs:utils:object:is:object:plain`: Invokes `isPlainObject`.
28+
* - `typhonjs:utils:object:is:record`: Invokes `isRecord`.
2729
* - `typhonjs:utils:object:keys`: Invokes `objectKeys`.
2830
* - `typhonjs:utils:object:klona`: Invokes `klona`.
2931
* - `typhonjs:utils:object:size`: Invokes `objectSize`.
@@ -49,6 +51,7 @@ export function onPluginLoad(ev)
4951
if (typeof options.guard === 'boolean') { guard = options.guard; }
5052
}
5153

54+
eventbus.on('typhonjs:utils:object:assert', OU.assertObject, void 0, { guard });
5255
eventbus.on('typhonjs:utils:object:deep:freeze', OU.deepFreeze, void 0, { guard });
5356
eventbus.on('typhonjs:utils:object:deep:merge', OU.deepMerge, void 0, { guard });
5457
eventbus.on('typhonjs:utils:object:deep:seal', OU.deepSeal, void 0, { guard });
@@ -64,6 +67,7 @@ export function onPluginLoad(ev)
6467
eventbus.on('typhonjs:utils:object:is:iterable', OU.isIterable, void 0, { guard });
6568
eventbus.on('typhonjs:utils:object:is:object', OU.isObject, void 0, { guard });
6669
eventbus.on('typhonjs:utils:object:is:object:plain', OU.isPlainObject, void 0, { guard });
70+
eventbus.on('typhonjs:utils:object:is:record', OU.isRecord, void 0, { guard });
6771
eventbus.on('typhonjs:utils:object:keys', OU.objectKeys, void 0, { guard });
6872
eventbus.on('typhonjs:utils:object:klona', OU.klona, void 0, { guard });
6973
eventbus.on('typhonjs:utils:object:size', OU.objectSize, void 0, { guard });

test/src/functions.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,21 @@ const s_VERIFY_SAFESET_SUB = `{"a":0,"b":0,"c":0,"array":[0,0,0],"level1":{"d":0
9494

9595
describe('ObjectUtil:', () =>
9696
{
97+
it('assertObject', () =>
98+
{
99+
assert.throws(() => ObjectUtil.assertObject(false), 'Expected an object.');
100+
assert.throws(() => ObjectUtil.assertObject(null), 'Expected an object.');
101+
assert.throws(() => ObjectUtil.assertObject(void 0), 'Expected an object.');
102+
assert.throws(() => ObjectUtil.assertObject([]), 'Expected an object.');
103+
104+
assert.throws(() => ObjectUtil.assertObject(void 0, 'Custom error message'), 'Custom error message');
105+
106+
// No-op visual type erasure check.
107+
const val: NoOpObj = { a: 123 };
108+
ObjectUtil.assertObject(val);
109+
expectTypeOf(val).toEqualTypeOf<NoOpObj>();
110+
});
111+
97112
it('deepFreeze w/ skipKeys:', () =>
98113
{
99114
const testObj = ObjectUtil.klona(s_OBJECT_DEEP);
@@ -728,6 +743,25 @@ describe('ObjectUtil:', () =>
728743
if (ObjectUtil.isPlainObject(val)) { expectTypeOf(val).toEqualTypeOf<NoOpObj>(); }
729744
});
730745

746+
it('isRecord', () =>
747+
{
748+
class Test {}
749+
750+
assert.isFalse(ObjectUtil.isRecord(false));
751+
assert.isFalse(ObjectUtil.isRecord(null));
752+
assert.isFalse(ObjectUtil.isRecord(void 0));
753+
assert.isFalse(ObjectUtil.isRecord('test'));
754+
755+
assert.isTrue(ObjectUtil.isRecord(new Test()));
756+
assert.isTrue(ObjectUtil.isRecord({}));
757+
assert.isTrue(ObjectUtil.isRecord(Object.create(null)));
758+
assert.isTrue(ObjectUtil.isRecord(new Object())); // eslint-disable-line no-new-object
759+
760+
// No-op visual type check.
761+
const val: NoOpObj = { a: 123 };
762+
if (ObjectUtil.isRecord(val)) { expectTypeOf(val).toEqualTypeOf<Record<string, unknown>>(); }
763+
});
764+
731765
it('objectKeys', () =>
732766
{
733767
// @ts-expect-error

0 commit comments

Comments
 (0)