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
3 changes: 1 addition & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,5 @@ The vocabulary we have established for the pipelean project:

* Operation: The function passed to an iterator (like series). It can be a simple function or a composed function (pipe).
* Transform (Mapping): An operation that changes the shape or value of an item. (A→B).
* Selection (Filtering): An operation that decides whether to keep or drop an item. (A→A or A→∅
). In our merged model, this is signaled by returning undefined.
* Selection (Filtering): An operation that decides whether to keep or drop an item. (A→A or A→∅). In our merged model, this is signaled by returning undefined.
* Outcome: The structural result returned by iterators: {results, errors, failure}.
12 changes: 12 additions & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,18 @@ const isValid = pipe(
const adults = await filter(isValid, users)
```

**Undefined Short-Circuit**: When any step returns `undefined`, remaining steps are skipped and `undefined` propagates out. Combined with `series` (which drops items when the operation returns `undefined`), this merges transformation and selection in a single pass:

```js
import { series, pipe } from 'pipelean'

const result = await series(numbers, pipe(
x => x % 2 === 0 ? x : undefined, // select: drop odds
x => x * 2, // transform: double
))
// result.results = [4, 8, 12] from inputs [2, 4, 6]
```

#### Wrappers

Pipelean also provides lightweight wrappers that add behavior to **individual functions**. These act as reusable middleware / lifecycle hooks and compose naturally with `pipe`.
Expand Down
56 changes: 31 additions & 25 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,41 +285,34 @@ const { results, errors } = await scan(

### filter

**Purpose**: Stateless selection tool - filters items from an iterable based on a predicate function.
**Purpose**: Stateless selection tool - filters items from an iterable based on a predicate function. Delegates to `series` internally: the predicate is converted to a transform that returns the original item (keep) or `undefined` (drop).

**Type**: `(...args) => filteredItems | filterFunction`
**Type**: `(...args) => Promise<Outcome> | filterFunction`

**Parameters**:
- First argument (optional): If a function, specifies `take`
- Remaining arguments: Items to filter
- If first arg is NOT a function: Treated as `iterable` and processed with `take` strategy
- `predicate`: A function `(item, index) => truthy | falsy`, or a plain object pattern (converted via `where()`)
- `items`: The iterable to filter (immediate mode)
- `opts` (optional): Options passed through to `series`

**Options**:
- `strategy`: Error strategy object (`failFast`, `collect`, `failLate`, `skip`, or aliases)
- `onError`: Optional callback called for each error
- `onFailure`: Optional callback called when `failure` is truthy (failFast: `{item, error}`, failLate: `true`)
- `take`: Optional number of items to collect
**Options**: Same as `series` — `strategy`, `onError`, `onFailure`, `take`, `onProgress`.

**Return Type**: `{ results, errors, failure }` — same shape as `series`:
- `results`: Original items where the predicate returned truthy
- `failure`: `false` on success (no errors), `{item, error}` for `failFast`, `true` for `failLate`

**Return Type**: Returns `{ results, errors, failure }` object (defaults to `collect`):
- With `collect` (default): `{ results, errors: [...], failure: null }`
- With `failFast`: `{ results, errors: [], failure: { item, error } }`
- With `failLate`: `{ results, errors: [...], failure: true }`
- With `skip`: `{ results, errors: [], failure: null }`
**Key Characteristics**:
- The predicate's return value is never placed into `results` — only truthiness is checked, and the original `item` is what gets kept or dropped.
- Pattern objects are supported: `filter({active: true}, users)` works via `where()`.

**Usage Example**:
```javascript
import { filter, failFast } from './functional.js'
import { filter } from 'pipelean'

// Filter valid emails from a list (default is collect)
const validEmails = await filter(
async (email) => {
return email.includes('@')
},
emails,
{
strategy: failFast // Override default to stop on first invalid email
}
const adults = await filter(
user => user.age >= 18,
users,
)
// result.results = [user1, user3, ...] — original items, not predicate output
```

---
Expand All @@ -343,6 +336,7 @@ const validEmails = await filter(
- Output of one function becomes input to the next
- Supports both synchronous and asynchronous functions
- Natural data flow from input through transformations
- **Undefined Short-Circuit**: If any step returns `undefined`, remaining steps are skipped and `undefined` is returned. This enables selection (filtering) within a composed pipe — see [series](#series) drop behavior.

**Usage Example**:
```javascript
Expand All @@ -367,6 +361,18 @@ const result = await pipe(

**Best Practice**: Use `pipe()` when you need to chain operations that form a coherent data processing pipeline.

**Selection in pipe** (via undefined short-circuit):
```javascript
import { pipe, series } from 'pipelean'

// Merge filter and transform in a single operation
const result = await series(items, pipe(
x => x.active ? x : undefined, // drop inactive items
x => x.name, // extract name
))
// Items where active is false are skipped entirely
```

---

## Misc
Expand Down
71 changes: 8 additions & 63 deletions src/functional.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,73 +137,18 @@ export const filter = (...args) => {
!Array.isArray(x)
const toPredicate = x => isPattern(x) ? where(x) : x
const immediate = typeof args[0] !== 'function' && !isPattern(args[0])
const [
items,
rawPredicate,
opts,
] = immediate ? args : [null, args[0], args[1]]
const [items, rawPredicate, opts] = immediate
? args
: [null, args[0], args[1]]
const predicate = toPredicate(rawPredicate)

// eslint-disable-next-line complexity, max-statements
const run = async inputItems => {
const {
strategy = collect,
onError: onErrorParam,
take,
onFailure,
} = opts || {}

const results = []
const errors = []

let index = 0
let failure = null

for await (const item of inputItems) {
// eslint-disable-next-line no-undefined
if (take !== undefined && results.length >= take) {
break
}

try {
const keep = await predicate(item, index)
if (keep) {
results.push(item)
}
} catch (error) {
const strategyName = strategy.name ?? strategy

if (onErrorParam) {
await onErrorParam(error)
}

if (strategyName === 'failFast') {
if (onFailure) {
onFailure({item, error})
}
return {results, errors, failure: {item, error}}
}

if (strategyName === 'skip') {
index++
continue
}

errors.push({item, error})
}

index++
}

failure = strategy.name === 'failLate' && errors.length > 0 ? true : null

if (failure && onFailure) {
onFailure(true)
}

return {results, errors, failure}
const transform = async (item, index) => {
const keep = await predicate(item, index)
// eslint-disable-next-line no-undefined
return keep ? item : undefined
}

const run = inputItems => series(inputItems, transform, opts)
return immediate ? run(items) : run
}

Expand Down
10 changes: 5 additions & 5 deletions tests/filter.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import {filter} from '$src/functional'

test('predicate truthy keeps item in results', async () => {
const result = await filter([1, 2, 3, 4], x => x > 2)
expect(result).toEqual({results: [3, 4], errors: [], failure: null})
expect(result).toEqual({results: [3, 4], errors: [], failure: false})
})

test('predicate falsy excludes item without error', async () => {
const result = await filter([1, 2, 3], () => false)
expect(result).toEqual({results: [], errors: [], failure: null})
expect(result).toEqual({results: [], errors: [], failure: false})
})

test('predicate throws with failFast stops and populates failure', async () => {
Expand All @@ -31,7 +31,7 @@ test('predicate throws with default collect collects errors', async () => {
return true
})
expect(result.results).toEqual([1, 3])
expect(result.failure).toBe(null)
expect(result.failure).toBe(false)
expect(result.errors).toEqual([
{item: 2, error: bang},
{item: 4, error: bang},
Expand All @@ -51,10 +51,10 @@ test('curried form returns a function', () => {
test('curried form executes when called with items', async () => {
const evens = filter(x => x % 2 === 0)
const result = await evens([1, 2, 3, 4])
expect(result).toEqual({results: [2, 4], errors: [], failure: null})
expect(result).toEqual({results: [2, 4], errors: [], failure: false})
})

test('empty array returns empty result shape', async () => {
const result = await filter([], () => true)
expect(result).toEqual({results: [], errors: [], failure: null})
expect(result).toEqual({results: [], errors: [], failure: false})
})