Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 21 additions & 7 deletions src/unique.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { isAsyncIterable } from './predicate/isAsyncIterable.js';
import { isIterable } from './predicate/isIterable.js';

export type MembershipStore<T> = {
has(value: T): boolean;
add(value: T): void;
};

export type UniqueOptions<Type> = {
/**
* Return a custom ID to uniquely identify an item.
Expand All @@ -9,9 +14,19 @@ export type UniqueOptions<Type> = {
/**
* Return `true` to yield the item.
*
* Use https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Iterator/filter when available.
* @todo Refactor to use `filter` on the (async)iterator when available.
* @see https://github.com/tc39/proposal-async-iterator-helpers
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Iterator/filter
*/
filter?: (item: Type) => boolean;
/**
* A store to keep track of which items have been seen.
*
* If you have a large dataset, you may want to use a more memory-efficient store than a `Set`, such as a Bloom filter.
*
* @default `Set<unknown>`.
*/
store?: MembershipStore<unknown>;
};

export function unique<Type>(items: AsyncIterable<Type>, options?: UniqueOptions<Type>): AsyncIterable<Type>;
Expand All @@ -27,8 +42,7 @@ export function unique<Type>(
items: AsyncIterable<Type> | Iterable<Type>,
options: UniqueOptions<Type> = {},
): AsyncIterable<Type> | Iterable<Type> {
const ids = new Set<unknown>();
const { filter, identity } = options;
const { filter, identity, store = new Set<unknown>() } = options;

if (typeof items === 'string' || isIterable(items)) {
return {
Expand All @@ -37,8 +51,8 @@ export function unique<Type>(
if (!filter || filter(item) === true) {
const id = identity?.(item) ?? item;

if (!ids.has(id)) {
ids.add(id);
if (!store.has(id)) {
store.add(id);

yield item;
}
Expand All @@ -55,8 +69,8 @@ export function unique<Type>(
if (!filter || filter(item) === true) {
const id = identity?.(item) ?? item;

if (!ids.has(id)) {
ids.add(id);
if (!store.has(id)) {
store.add(id);

yield item;
}
Expand Down
38 changes: 36 additions & 2 deletions test/unique.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';

import { unique } from '../src/unique.js';
import { unique, type MembershipStore } from '../src/unique.js';

describe('unique()', () => {
it('Yields unique items from an Iterable', () => {
Expand Down Expand Up @@ -70,4 +70,38 @@ describe('unique()', () => {
unique(false as unknown as Iterable<boolean>);
}).toThrow();
});

it('Can use a custom MembershipStore via the `store` option', () => {
const has = vi.fn((value: unknown) => value === 2);
const add = vi.fn();

const store: MembershipStore<unknown> = { has, add };

expect([...unique([1, 2, 3], { store })]).toEqual([1, 3]);

expect(has).toHaveBeenCalledWith(1);
expect(has).toHaveBeenCalledWith(2);
expect(has).toHaveBeenCalledWith(3);
expect(add).toHaveBeenCalledWith(1);
expect(add).not.toHaveBeenCalledWith(2);
expect(add).toHaveBeenCalledWith(3);
});

it('Shares uniqueness state across calls when the same `store` is reused', () => {
const store: MembershipStore<unknown> = new Set();

expect([...unique([1, 2, 3], { store })]).toEqual([1, 2, 3]);
expect([...unique([2, 3, 4], { store })]).toEqual([4]);
});

it('Uses the `store` option with an AsyncIterable', async () => {
const demo = async function* (): AsyncGenerator<number> {
yield 1;
yield 2;
yield 2;
yield 3;
};

await expect(Array.fromAsync(unique(demo(), { store: new Set([2]) }))).resolves.toEqual([1, 3]);
});
});
Loading