Skip to content

Commit 42b7daa

Browse files
committed
Added ensureNonEmptyIterable, ensureNonEmptyAsyncIterable, and isPlainObjectEmpty.
1 parent 2188f1d commit 42b7daa

File tree

3 files changed

+206
-12
lines changed

3 files changed

+206
-12
lines changed

src/functions.ts

Lines changed: 126 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,114 @@ export function deepSeal<T extends object | []>(data: T, { skipKeys }: { skipKey
232232
return data;
233233
}
234234

235+
/**
236+
* Ensures that a value is a *non-empty async iterable*.
237+
* ```
238+
* - If the value is not async iterable, `undefined` is returned.
239+
* - If the async iterable yields no items, `undefined` is returned.
240+
* - If it yields at least one item, a fresh async iterable is returned which yields the first peeked item followed by
241+
* the rest, preserving behavior for one-shot async generators.
242+
* ```
243+
*
244+
* Supports both AsyncIterable<T> and (optionally) synchronous Iterable<T>.
245+
*
246+
* @param value - The value to test as an async iterable.
247+
*
248+
* @returns A non-empty async iterable, or `undefined`.
249+
*/
250+
export async function ensureNonEmptyAsyncIterable<T>(value: AsyncIterable<T> | Iterable<T> | null | undefined):
251+
Promise<AsyncIterable<T> | undefined>
252+
{
253+
// First detect async-iterable-like values.
254+
const asyncIteratorFn = value?.[Symbol.asyncIterator];
255+
const syncIteratorFn = value?.[Symbol.iterator];
256+
257+
if (asyncIteratorFn)
258+
{
259+
const iter = asyncIteratorFn.call(value);
260+
const first = await iter.next();
261+
262+
if (first.done) { return void 0; }
263+
264+
return (async function* (): AsyncGenerator<T, void, unknown>
265+
{
266+
// Yield peeked first value.
267+
yield first.value;
268+
269+
// Manually consume the underlying async iterator.
270+
for (let r = await iter.next(); !r.done; r = await iter.next()) { yield r.value; }
271+
})();
272+
}
273+
else if (syncIteratorFn)
274+
{
275+
// Allow synchronous iterables to be lifted into async context.
276+
const iter = syncIteratorFn.call(value);
277+
const first = iter.next();
278+
279+
if (first.done) { return void 0; }
280+
281+
return (async function* (): AsyncGenerator<T, void, unknown>
282+
{
283+
yield first.value;
284+
285+
for (let r = iter.next(); !r.done; r = iter.next()) { yield r.value; }
286+
})();
287+
}
288+
289+
return void 0;
290+
}
291+
292+
/**
293+
* Ensures that a given value is a *non-empty iterable*.
294+
* ```
295+
* - If the value is not iterable → returns `undefined`.
296+
* - If the value is an iterable but contains no entries → returns `undefined`.
297+
* - If the value is a non-empty iterable → returns a fresh iterable (generator) that yields the first peeked value
298+
* followed by the remaining values. This guarantees restartable iteration even when the original iterable is a
299+
* one-shot generator.
300+
* ```
301+
*
302+
* This function is ideal when you need a safe, non-empty iterable for iteration but cannot consume or trust the
303+
* original iterable’s internal iterator state.
304+
*
305+
* @param value - The value to inspect.
306+
*
307+
* @returns A restartable iterable containing all values, or `false` if the input was not iterable or contained no
308+
* items.
309+
*
310+
* @example
311+
* const iter = ensureNonEmptyIterable(['a', 'b']);
312+
* // `iter` is an iterable yielding 'a', 'b'.
313+
*
314+
* const empty = ensureNonEmptyIterable([]);
315+
* // `undefined`
316+
*
317+
* const gen = ensureNonEmptyIterable((function*(){ yield 1; yield 2; })());
318+
* // Safe: returns an iterable yielding 1, 2 without consuming the generator.
319+
*/
320+
export function ensureNonEmptyIterable<T>(value: Iterable<T> | null | undefined): Iterable<T> | undefined
321+
{
322+
if (!isIterable(value)) { return void 0; }
323+
324+
// Peek at the first value without committing to iteration on restartable iterables.
325+
const iter = value[Symbol.iterator]();
326+
327+
const first = iter.next();
328+
329+
// Empty iterable.
330+
if (first.done) { return void 0; }
331+
332+
// Non-empty: return a generator that includes the first peeked value.
333+
return (function* ()
334+
{
335+
// Include first consumed value.
336+
yield first.value;
337+
338+
// Yield remaining values from original iterator.
339+
for (let r = iter.next(); !r.done; r = iter.next()) { yield r.value; }
340+
})();
341+
}
342+
235343
/**
236344
* Determine if the given object has a getter & setter accessor.
237345
*
@@ -359,9 +467,7 @@ export function hasSetter<T extends object, K extends string>(object: T, accesso
359467
*/
360468
export function isAsyncIterable(value: unknown): value is AsyncIterable<any>
361469
{
362-
if (typeof value !== 'object' || value === null || value === void 0) { return false; }
363-
364-
return Symbol.asyncIterator in value;
470+
return value !== void 0 && value !== null && typeof (value as any)[Symbol.asyncIterator] === 'function';
365471
}
366472

367473
/**
@@ -373,9 +479,7 @@ export function isAsyncIterable(value: unknown): value is AsyncIterable<any>
373479
*/
374480
export function isIterable(value: unknown): value is Iterable<any>
375481
{
376-
if (value === null || value === void 0 || typeof value !== 'object') { return false; }
377-
378-
return Symbol.iterator in value;
482+
return value !== void 0 && value !== null && typeof (value as any)[Symbol.iterator] === 'function';
379483
}
380484

381485
/**
@@ -407,6 +511,22 @@ export function isPlainObject(value: unknown): value is Record<string, unknown>
407511
return prototype === null || prototype === Object.prototype;
408512
}
409513

514+
/**
515+
* Test for an empty plain object.
516+
*
517+
* A strict check: only plain objects ({}) qualify, and only if they have no own enumerable keys.
518+
*
519+
* @returns `true` if the value is a plain object with no enumerable properties.
520+
*/
521+
export function isPlainObjectEmpty(value: unknown): value is Record<string, never>
522+
{
523+
if (!isPlainObject(value)) { return false; }
524+
525+
for (const key in value) { return false; }
526+
527+
return true;
528+
}
529+
410530
/**
411531
* Safely returns keys on an object or an empty array if not an object.
412532
*

src/plugin.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,17 @@ import * as OU from './functions.js';
1414
* - `typhonjs:utils:object:deep:freeze`: Invokes `deepFreeze`.
1515
* - `typhonjs:utils:object:deep:merge`: Invokes `deepMerge`.
1616
* - `typhonjs:utils:object:deep:seal`: Invokes `deepSeal`.
17+
* - `typhonjs:utils:object:ensure:non:empty:iterable:async`: Invokes `ensureNonEmptyAsyncIterable`.
18+
* - `typhonjs:utils:object:ensure:non:empty:iterable`: Invokes `ensureNonEmptyIterable`.
1719
* - `typhonjs:utils:object:has:accessor`: Invokes `hasAccessor`.
1820
* - `typhonjs:utils:object:has:getter`: Invokes `hasGetter`.
1921
* - `typhonjs:utils:object:has:prototype`: Invokes `hasPrototype`.
2022
* - `typhonjs:utils:object:has:setter`: Invokes `hasSetter`.
2123
* - `typhonjs:utils:object:is:iterable:async`: Invokes `isAsyncIterable`.
2224
* - `typhonjs:utils:object:is:iterable`: Invokes `isIterable`.
2325
* - `typhonjs:utils:object:is:object`: Invokes `isObject`.
26+
* - `typhonjs:utils:object:is:object:plain`: Invokes `isPlainObject`.
27+
* - `typhonjs:utils:object:is:object:plain:empty`: Invokes `isPlainObjectEmpty`.
2428
* - `typhonjs:utils:object:keys`: Invokes `objectKeys`.
2529
* - `typhonjs:utils:object:klona`: Invokes `klona`.
2630
* - `typhonjs:utils:object:size`: Invokes `objectSize`.
@@ -49,6 +53,10 @@ export function onPluginLoad(ev)
4953
eventbus.on('typhonjs:utils:object:deep:freeze', OU.deepFreeze, void 0, { guard });
5054
eventbus.on('typhonjs:utils:object:deep:merge', OU.deepMerge, void 0, { guard });
5155
eventbus.on('typhonjs:utils:object:deep:seal', OU.deepSeal, void 0, { guard });
56+
eventbus.on('typhonjs:utils:object:ensure:non:empty:iterable:async', OU.ensureNonEmptyAsyncIterable, void 0,
57+
{ guard });
58+
eventbus.on('typhonjs:utils:object:ensure:non:empty:iterable', OU.ensureNonEmptyIterable, void 0, { guard });
59+
eventbus.on('typhonjs:utils:object:deep:seal', OU.deepSeal, void 0, { guard });
5260
eventbus.on('typhonjs:utils:object:has:accessor', OU.hasAccessor, void 0, { guard });
5361
eventbus.on('typhonjs:utils:object:has:getter', OU.hasGetter, void 0, { guard });
5462
eventbus.on('typhonjs:utils:object:has:prototype', OU.hasPrototype, void 0, { guard });
@@ -57,6 +65,7 @@ export function onPluginLoad(ev)
5765
eventbus.on('typhonjs:utils:object:is:iterable', OU.isIterable, void 0, { guard });
5866
eventbus.on('typhonjs:utils:object:is:object', OU.isObject, void 0, { guard });
5967
eventbus.on('typhonjs:utils:object:is:object:plain', OU.isPlainObject, void 0, { guard });
68+
eventbus.on('typhonjs:utils:object:is:object:plain:empty', OU.isPlainObjectEmpty, void 0, { guard });
6069
eventbus.on('typhonjs:utils:object:keys', OU.objectKeys, void 0, { guard });
6170
eventbus.on('typhonjs:utils:object:klona', OU.klona, void 0, { guard });
6271
eventbus.on('typhonjs:utils:object:size', OU.objectSize, void 0, { guard });

test/src/functions.test.ts

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import * as ObjectUtil from '../../src/functions.js';
1+
import * as ObjectUtil from '../../src/functions';
22

33
const s_OBJECT_DEEP =
44
{
@@ -149,14 +149,14 @@ describe('ObjectUtil:', () =>
149149
assert.isTrue(Object.isFrozen(testObj.level1.level2.array2[2][0]));
150150

151151
// Make sure pushing to arrays fails.
152-
// @ts-expect-error
152+
// @ts-expect-error
153153
assert.throws(() => { testObj.a.a1.push(1); }); // @ts-expect-error
154154
assert.throws(() => { testObj.c.push(1); }); // @ts-expect-error
155155
assert.throws(() => { testObj.array.push(1); }); // @ts-expect-error
156156
assert.throws(() => { testObj.array[0].push(1); }); // @ts-expect-error
157157
assert.throws(() => { testObj.array[1].push(1); }); // @ts-expect-error
158158
assert.throws(() => { testObj.array[2].push(1); });
159-
// @ts-expect-error
159+
// @ts-expect-error
160160
assert.throws(() => { testObj.level1.d.d1.push(1); }); // @ts-expect-error
161161
assert.throws(() => { testObj.level1.f.push(1); }); // @ts-expect-error
162162
assert.throws(() => { testObj.level1.array1.push(1); }); // @ts-expect-error
@@ -430,14 +430,14 @@ describe('ObjectUtil:', () =>
430430
assert.isTrue(Object.isSealed(testObj.level1.level2.array2[2][0]));
431431

432432
// Make sure pushing to arrays fails.
433-
// @ts-expect-error
433+
// @ts-expect-error
434434
assert.throws(() => { testObj.a.a1.push(1); }); // @ts-expect-error
435435
assert.throws(() => { testObj.c.push(1); }); // @ts-expect-error
436436
assert.throws(() => { testObj.array.push(1); }); // @ts-expect-error
437437
assert.throws(() => { testObj.array[0].push(1); }); // @ts-expect-error
438438
assert.throws(() => { testObj.array[1].push(1); }); // @ts-expect-error
439439
assert.throws(() => { testObj.array[2].push(1); });
440-
// @ts-expect-error
440+
// @ts-expect-error
441441
assert.throws(() => { testObj.level1.d.d1.push(1); }); // @ts-expect-error
442442
assert.throws(() => { testObj.level1.f.push(1); }); // @ts-expect-error
443443
assert.throws(() => { testObj.level1.array1.push(1); }); // @ts-expect-error
@@ -467,6 +467,55 @@ describe('ObjectUtil:', () =>
467467
assert.isTrue(Object.isSealed(testObj.level1.level2.skipKey.s3));
468468
});
469469

470+
471+
it('ensureNonEmptyAsyncIterable:', async () =>
472+
{
473+
// @ts-expect-error
474+
assert.isUndefined(await ObjectUtil.ensureNonEmptyAsyncIterable(false));
475+
476+
assert.isUndefined(await ObjectUtil.ensureNonEmptyAsyncIterable((async function *generator() {})()));
477+
assert.isUndefined(await ObjectUtil.ensureNonEmptyAsyncIterable(null));
478+
assert.isUndefined(await ObjectUtil.ensureNonEmptyAsyncIterable(void 0));
479+
assert.isUndefined(await ObjectUtil.ensureNonEmptyAsyncIterable([]));
480+
assert.isUndefined(await ObjectUtil.ensureNonEmptyAsyncIterable((function *generator() {})()));
481+
482+
const asyncIter1 = await ObjectUtil.ensureNonEmptyAsyncIterable(
483+
(async function *generator() { yield 1; yield 2; })());
484+
485+
const asyncIter2 = await ObjectUtil.ensureNonEmptyAsyncIterable((function *generator() { yield 1; yield 2; })());
486+
487+
const asyncIter3 = await ObjectUtil.ensureNonEmptyAsyncIterable([1, 2]);
488+
489+
const result1 = [];
490+
const result2 = [];
491+
const result3 = [];
492+
493+
for await (const v of asyncIter1) { result1.push(v); }
494+
for await (const v of asyncIter2) { result2.push(v); }
495+
for await (const v of asyncIter3) { result3.push(v); }
496+
497+
assert.deepEqual(result1, [1, 2]);
498+
assert.deepEqual(result2, [1, 2]);
499+
assert.deepEqual(result3, [1, 2]);
500+
});
501+
502+
it('ensureNonEmptyIterable:', () =>
503+
{
504+
// @ts-expect-error
505+
assert.isUndefined(ObjectUtil.ensureNonEmptyIterable(false));
506+
507+
// @ts-expect-error
508+
assert.isUndefined(ObjectUtil.ensureNonEmptyIterable((async function *generator() {})()));
509+
510+
assert.isUndefined(ObjectUtil.ensureNonEmptyIterable(null));
511+
assert.isUndefined(ObjectUtil.ensureNonEmptyIterable(void 0));
512+
assert.isUndefined(ObjectUtil.ensureNonEmptyIterable([]));
513+
assert.isUndefined(ObjectUtil.ensureNonEmptyIterable((function *generator() {})()));
514+
515+
assert.deepEqual([...ObjectUtil.ensureNonEmptyIterable((function *generator() { yield 1; yield 2; })())], [1, 2]);
516+
assert.deepEqual([...ObjectUtil.ensureNonEmptyIterable([1, 2])], [1, 2]);
517+
});
518+
470519
describe('hasAccessor:', () =>
471520
{
472521
it('top level', () =>
@@ -602,9 +651,9 @@ describe('ObjectUtil:', () =>
602651
assert.isFalse(ObjectUtil.isIterable(false));
603652
assert.isFalse(ObjectUtil.isIterable(null));
604653
assert.isFalse(ObjectUtil.isIterable({}));
605-
assert.isFalse(ObjectUtil.isIterable(''));
606654
assert.isFalse(ObjectUtil.isIterable((async function *generator() {})()));
607655

656+
assert.isTrue(ObjectUtil.isIterable('123'));
608657
assert.isTrue(ObjectUtil.isIterable(new Set('a')));
609658
assert.isTrue(ObjectUtil.isIterable((function *generator() {})()));
610659
});
@@ -645,6 +694,22 @@ describe('ObjectUtil:', () =>
645694
assert.isTrue(ObjectUtil.isPlainObject(new Object())); // eslint-disable-line no-new-object
646695
});
647696

697+
it('isPlainObjectEmpty', () =>
698+
{
699+
class Test {}
700+
701+
assert.isFalse(ObjectUtil.isPlainObjectEmpty(false));
702+
assert.isFalse(ObjectUtil.isPlainObjectEmpty(null));
703+
assert.isFalse(ObjectUtil.isPlainObjectEmpty(void 0));
704+
assert.isFalse(ObjectUtil.isPlainObjectEmpty(new String('test')));
705+
assert.isFalse(ObjectUtil.isPlainObjectEmpty(new Test()));
706+
assert.isFalse(ObjectUtil.isPlainObjectEmpty({ foo: 'bar ' }));
707+
708+
assert.isTrue(ObjectUtil.isPlainObjectEmpty({}));
709+
assert.isTrue(ObjectUtil.isPlainObjectEmpty(Object.create(null)));
710+
assert.isTrue(ObjectUtil.isPlainObjectEmpty(new Object())); // eslint-disable-line no-new-object
711+
});
712+
648713
it('objectKeys', () =>
649714
{
650715
// @ts-expect-error

0 commit comments

Comments
 (0)