Idoimatic Typescript API for iterating over any paginated data.
When working with paginated APIs or data sources, developers often need to:
- Handle both offset-based and cursor-based pagination
- Deal with errors gracefully without breaking the entire data fetch
- Repeat boilerplate pagination logic
- Manage state between pagination requests
This library solves these problems by providing a simple async iterator that handles all the complexity of pagination while giving you complete control over error handling.
npm install @pushpress/paginate
# or
yarn add @pushpress/paginate
# or
pnpm add @pushpress/paginate
import { paginate } from "paginate";
// Create an async iterator over your paginated data
const iterator = paginate(
async ({ limit, offset }) => {
const response = await fetch(`/api/items?limit=${limit}&offset=${offset}`);
const data = await response.json();
return {
items: data.items,
pageInfo: {
hasNextPage: data.items.length === limit,
},
};
},
{
strategy: "offset",
limit: 10,
errorPolicy: { type: "throw" },
},
);
// Iterate over all items
for await (const item of iterator) {
console.log(item);
}
const iterator = paginate(
async ({ limit, cursor }) => {
const response = await fetch(
`/api/items?limit=${limit}${cursor ? `&cursor=${cursor}` : ""}`,
);
const data = await response.json();
return {
items: data.items,
pageInfo: {
hasNextPage: data.hasMore,
nextCursor: data.nextCursor,
},
};
},
{
strategy: "cursor",
limit: 25,
errorPolicy: {
type: "continue",
maxErrorCount: 3,
onError: async (error) => {
// Log errors but continue pagination
console.error("Pagination error:", error);
},
},
},
);
for await (const item of iterator) {
// proces item
await processItem(item);
}
The paginate()
function returns a FluentAsyncIterable<T>
that implements both AsyncIterable<T>
natively AND provides a fluent interface with methods for chaining operations.
import { paginate } from "@pushpress/paginate";
// Traditional async iteration still works
for await (const user of paginate(getUsersCallback, options)) {
console.log(user.name);
}
// NEW: Fluent interface for data processing
const activeUserEmails = await paginate(getUsersCallback, {
strategy: "offset",
limit: 10,
errorPolicy: { type: "throw" },
})
.filter((user) => user.isActive)
.map((user) => user.email.toLowerCase())
.toArray();
Transformation Methods (return new FluentAsyncIterable
):
.filter(predicate)
- Filter items.map(transform)
- Transform items.take(count)
- Take first N items.skip(count)
- Skip first N items
Terminal Methods (execute and return results):
.toArray()
- Collect all items into an array.toSet()
- Collect unique items into a Set.toMap(keyFn)
- Collect items into a Map using a key function.forEach(fn)
- Execute a function for each item.reduce(reducer, initialValue)
- Reduce items to a single value.find(predicate)
- Find first matching item.some(predicate)
- Test if any items match.every(predicate)
- Test if all items match
Data Processing Pipeline:
// Process user data with multiple transformations
const processedUsers = await paginate(getUsersCallback, options)
.filter((user) => user.isActive && user.email)
.map(async (user) => ({
...user,
displayName: `${user.name} (${user.age} years old)`,
emailDomain: user.email.split("@")[1],
}))
.filter((user) => user.age >= 18)
.toArray();
Creating Lookup Structures:
// Create a Map of active users by ID
const userMap = await paginate(getUsersCallback, options)
.filter((user) => user.isActive)
.toMap((user) => user.id);
// Get unique email domains
const emailDomains = await paginate(getUsersCallback, options)
.map((user) => user.email.split("@")[1])
.toSet();
Early Termination:
// Find first user over 30
const matureUser = await paginate(getUsersCallback, options).find(
(user) => user.age > 30,
);
// Check if any users are inactive
const hasInactiveUsers = await paginate(getUsersCallback, options).some(
(user) => !user.isActive,
);
// Take only first 5 users
const firstFive = await paginate(getUsersCallback, options).take(5).toArray();
Aggregation:
// Calculate average age of active users
const avgAge = await paginate(getUsersCallback, options)
.filter((user) => user.isActive)
.reduce((sum, user, index) => {
return index === 0 ? user.age : (sum * index + user.age) / (index + 1);
}, 0);
Mixed Usage:
// Use fluent methods to filter, then iterate manually
const activeUsers = paginate(getUsersCallback, options)
.filter((user) => user.isActive)
.filter((user) => user.age >= 30);
for await (const user of activeUsers) {
await processUser(user);
}
Error Handling with Fluent Interface:
// Fluent interface works seamlessly with error policies
const results = await paginate(callback, {
strategy: "offset",
limit: 10,
errorPolicy: {
type: "continue",
maxErrorCount: 3,
},
})
.filter((item) => item.isValid)
.map((item) => processItem(item))
.toArray();
For functional programming enthusiasts, all fluent methods are also available as standalone utility functions:
import {
paginate,
filter,
map,
take,
toArray,
toSet,
find,
} from "@pushpress/paginate";
// Functional composition style
const result = await toArray(
take(
map(
filter(paginate(callback, options), (user) => user.isActive),
(user) => user.email.toLowerCase(),
),
10,
),
);
// All utilities support both sync and async predicates/transforms
const asyncFiltered = filter(
paginate(callback, options),
async (user) => await validateUser(user),
);
Creates a FluentAsyncIterable<T>
that yields items from a paginated data source.
type PaginationCallback<T> = (params: {
limit: number;
offset?: number;
cursor?: string | null;
}) => Promise<{
items: T[];
pageInfo: {
hasNextPage: boolean;
nextCursor?: string | null;
};
}>;
type PaginationOptions = {
strategy: "offset" | "cursor";
limit: number;
initialOffset?: number; // For offset-based pagination
initialCursor?: string | null; // For cursor-based pagination
logger?: Logger; // Optional logger interface
errorPolicy: ErrorPolicy;
};
type ErrorPolicy =
| {
type: "continue";
maxErrorCount: number;
onError?: (error: unknown) => void | Promise<void>;
}
| {
type: "throw";
}
| {
type: "break";
onError?: (error: unknown) => void | Promise<void>;
}
| {
type: "custom";
handler: (
error: unknown,
context: { consecutiveErrors: number },
) => boolean | Promise<boolean>;
};
The library provides four error handling strategies:
-
Continue (
{ type: "continue", maxErrorCount, onError? }
): Attempts to continue pagination after errors, but stops if too many consecutive errors occur. Optional error callback for logging or monitoring. -
Break (
{ type: "break", onError? }
): Stops iteration silently on error. Optional error callback for cleanup or logging. -
Throw (
{ type: "throw" }
): Throws errors immediately, stopping iteration. -
Custom (
{ type: "custom", handler }
): Provides full control over error handling decisions.
Error callbacks can be specified per policy type:
const iterator = paginate(callback, {
strategy: "offset",
limit: 10,
errorPolicy: {
type: "continue",
maxErrorCount: 3,
onError: async (error) => {
await reportError(error);
await cleanup();
},
},
});
// Or with break policy
const iterator2 = paginate(callback, {
strategy: "offset",
limit: 10,
errorPolicy: {
type: "break",
onError: async (error) => {
await notifyUser("Pagination stopped due to error");
},
},
});
The custom error policy allows you to implement complex error handling logic:
const iterator = paginate(callback, {
strategy: "offset",
limit: 10,
errorPolicy: {
type: "custom",
handler: async (error, { consecutiveErrors }) => {
// Log error to monitoring service
await reportError(error);
// Rate limiting logic
if (error instanceof RateLimitError) {
await delay(1000);
return consecutiveErrors <= 3; // retry up to 3 times
}
// Network error handling
if (error instanceof NetworkError) {
const isHealthy = await checkServiceHealth();
return isHealthy && consecutiveErrors < 5;
}
return false; // break for other errors
},
},
});
The custom handler receives:
- The error that occurred
- Context including the number of consecutive errors
It must return (or resolve to):
true
: Continue paginationfalse
: Stop pagination
-
Choose the right pagination strategy:
- Use offset-based for small to medium datasets with random access needs
- Use cursor-based for large datasets or real-time data
-
Set appropriate limits: Balance network requests with memory usage
-
Handle errors appropriately:
- Use "continue" with
maxErrorCount
for resilient data processing - Use "break" for optional data that can be partial
- Use "throw" for critical data that must be complete and when errors are expected
- Use "custom" for complex error handling requirements
- Use "continue" with
-
Use error callbacks effectively:
- Log errors for monitoring and debugging
- Clean up resources when pagination stops