🔧 Composition Utilities
The Nodeblocks SDK provides essential utilities for composing handlers, handling asynchronous operations, and building complex business logic pipelines. These utilities enable you to build complex, error-safe pipelines by combining simple functions in predictable ways.
🎯 Overview
Composition utilities provide the building blocks for robust and maintainable business logic. They handle function composition and error propagation, allowing you to chain operations safely and efficiently.
Key Features
- Function Composition: Combine multiple functions into single pipelines
- Error-Safe Operations: Automatic error propagation and handling
- Async Support: Seamless handling of asynchronous operations
- Type Safety: Full TypeScript support with proper typing
🔗 Basic Composition
For conceptual background on composition, currying, and Result types, see Functional Programming ».
compose
Combines multiple functions into a single pipeline. In the SDK, compose is an alias for Ramda's pipe — functions run left to right (not standard Ramda compose, which runs right to left):
import { primitives } from '@nodeblocks/backend-sdk';
const { compose } = primitives;
const processUser = compose(
validateUser, // 1. runs first
saveUser, // 2. runs second
formatResponse // 3. runs last
);
// Equivalent to: formatResponse(saveUser(validateUser(input)))
lift
In route handler chains, lift adapts the terminator step. It awaits the Promise from the previous composed step, then passes the unwrapped value to its function — typically orThrow, which maps block errors to HTTP status codes and formats the response:
import { primitives, blocks } from '@nodeblocks/backend-sdk';
const { compose, flatMapAsync, lift, applyPayloadArgs, orThrow } = primitives;
const { getProductById, normalizeProduct, ProductNotFoundBlockError } = blocks;
// lift wraps orThrow — the standard terminator pattern
const handler = compose(
applyPayloadArgs(getProductById, [
['context', 'db', 'products'],
['params', 'requestParams', 'productId'],
], 'product'),
flatMapAsync(
applyPayloadArgs(normalizeProduct, [['context', 'data', 'product']], 'normalizedProduct')
),
lift(
orThrow(
[[ProductNotFoundBlockError, 404]],
[['context', 'data', 'normalizedProduct']]
)
)
);
lift does not lift plain functions into the Result functor. It bridges the async Promise between composed handler steps and the synchronous terminator.
mergeData
Merges data into the payload context for use by subsequent handlers in a composition pipeline.
import { ok } from 'neverthrow';
import { handlers, primitives } from '@nodeblocks/backend-sdk';
const { mergeData } = handlers;
const { compose, flatMapAsync, lift, orThrow } = primitives;
const enrichUserData = async (payload: RouteHandlerPayload) => {
const profile = await profileService.getProfile(payload.context.data.userId);
return ok(mergeData(payload, { profile }));
};
const getUserHandler = compose(
fetchUserFromDb,
flatMapAsync(enrichUserData), // profile is now available in payload.context.data
lift(orThrow([[UserNotFoundError, 404]], [['context', 'data', 'profile']]))
);
Parameters:
payload: The current payload objectdata: Object containing data to merge intopayload.context.data
Returns: Updated payload object with merged data
Note: mergeData returns a payload, not a Result. Handlers used with flatMapAsync must wrap the return in ok() (or err() on failure).
Usage Examples:
import { ok } from 'neverthrow';
// Merge single value (inside a flatMapAsync handler)
return ok(mergeData(payload, { userId: '123' }));
// Merge multiple values
return ok(mergeData(payload, {
user: userData,
profile: profileData,
settings: settingsData,
}));
// Merge in async handlers
const createUser = async (payload: RouteHandlerPayload) => {
const user = await db.users.create(payload.params.requestBody);
return ok(mergeData(payload, { user }));
};
applyPayloadArgs
Extracts arguments from payload and applies them to a pure function, enabling seamless integration of pure business logic into route handlers.
Purpose: Bridges pure functions with route payload context by extracting specific paths and applying them as function arguments
Handler Process:
- Input:
RouteHandlerPayloadwith context, params, and body data - Process: Extracts specified paths from payload and applies them to pure function (async or sync)
- Output:
Result<RouteHandlerPayload, Error>with function result merged into payload - Errors: Propagates errors when function returns Result.err
Parameters:
func: Pure function to execute with extracted arguments (supports both async and sync). Can use Result types.funcArgs: Array of paths to extract from payload (direct keys or nested paths)key: Optional property name to store the function result inpayload.context.data[key](viamergeData)
Returns: AsyncRouteHandler that applies pure function with payload-extracted arguments
Usage Examples:
import { primitives } from '@nodeblocks/backend-sdk';
const { applyPayloadArgs } = primitives;
// Used in route composition with async function:
const updateRoute = withRoute({
handler: compose(
applyPayloadArgs(updateIdentityPure, [
['params', 'requestParams', 'identityId'],
['params', 'requestBody'],
['context', 'db', 'identities']
], 'identityId')
)
});
// Use flatMapAsync when combining:
const updateRoute = withRoute({
handler: compose(
applyPayloadArgs(updateItem, [
['params', 'requestParams', 'itemId'],
['params', 'requestBody'],
['context', 'db', 'items']
], 'itemId'),
flatMapAsync(
applyPayloadArgs(
getItemById,
[
['context', 'data', 'itemId'],
['context', 'db', 'items'],
],
'item'
)
),
flatMapAsync(
applyPayloadArgs(
getOtherItemById,
[
['context', 'data', 'itemId'],
['context', 'db', 'otherItems'],
],
'item'
)
),
lift(
orThrow([[ItemNotFoundError, 404]], [['context', 'data', 'item']])
)
)
});
// Works with sync functions too:
applyPayloadArgs(someSyncFunction, [
['params', 'requestParams', 'id'],
['body']
], 'result')
// Handles Result types automatically:
applyPayloadArgs((x: number, y: number) => ok(x + y), [
['params', 'requestParams', 'x'],
['context', 'data', 'y']
], 'result')
orThrow
Terminator function that handles Result success/error cases with custom error mapping and optional success data extraction.
Purpose: Provides controlled error handling and response formatting for composition pipelines
Response Formatting:
- Input:
ResultcontainingRouteHandlerPayloadorError - Process: Maps specific error types to HTTP status codes or custom errors, optionally extracts data from success payload
- Output: With
successMap, returns the extracted success value and optional status code; without it, returns the originalRouteHandlerPayload. Throws mapped errors with appropriate status codes on failure. - Errors: Throws
NodeblocksErrorwith status code or custom error instances
Parameters:
errorMap: Array of[CustomError, HttpStatusCode | Error]tuples for error mappingsuccessMap: Optional[ObjectPath, HttpStatusCode]tuple for success data extraction
Returns: Function that processes Result, extracts success data when configured, or throws a mapped error
Usage Examples:
import { primitives } from '@nodeblocks/backend-sdk';
const { compose, lift, orThrow } = primitives;
// Used in route composition with error mapping:
const createUserRoute = withRoute({
handler: compose(
createUserHandler,
lift(orThrow([
[ValidationError, 400],
[DuplicateError, 409],
[DatabaseError, 500]
]))
)
});
// With success data extraction:
const getUserRoute = withRoute({
handler: compose(
getUserHandler,
lift(orThrow(
[[UserNotFoundError, 404]],
[['context', 'data', 'user'], 200]
))
)
});
🔎 Helpers
match
Predicate utility that checks a nested path via Ramda's pathSatisfies.
import { primitives } from '@nodeblocks/backend-sdk';
const { match } = primitives;
// Curried usage (2 args): returns a predicate that expects the object later
const isPositiveLimit = match(
(x: unknown) => Number(x) > 0,
['params', 'requestQuery', 'limit']
);
// later, supply the object
const ok = isPositiveLimit(payload); // boolean
// Direct usage (3 args): pass the object immediately
const okImmediate = match(
(x: unknown) => Number(x) > 0,
['params', 'requestQuery', 'limit'],
payload
); // boolean
ifElse
Functional conditional that selects one of two functions based on a predicate.
import { primitives } from '@nodeblocks/backend-sdk';
const { ifElse } = primitives;
const normalizeName = ifElse(
(x: any) => !!x.nickname,
(x: any) => x.nickname,
(x: any) => `${x.firstName} ${x.lastName}`
);
hasValue
Composite predicate returning true when a value is non-null and non-empty.
import { primitives } from '@nodeblocks/backend-sdk';
const { hasValue } = primitives;
hasValue('hello'); // true
hasValue(''); // false
hasValue(null); // false
hasValue(undefined); // false
hasValue([]); // false
hasValue({}); // false
hasValue({ a: 1 }); // true
hasValue([1, 2, 3]); // true
either
Ramda either re-export — logical OR for predicates. Returns a predicate that is true when either argument predicate is true.
import { utils, primitives } from '@nodeblocks/backend-sdk';
const { isCookieMode } = utils;
const { either, match } = primitives;
const isCookieAuth = either(
match(isCookieMode, ['context', 'authMode']),
match(isCookieMode, ['context', 'configuration', 'authMode'])
);
Used internally by whenCookieAuth to detect cookie mode from context or configuration.
mapMatchingErrorToFalse
Wraps a function that returns Promise<Result<T, Error>> and converts matching block errors to ok(false). Other errors propagate unchanged.
import { ok } from 'neverthrow';
import { primitives, blocks } from '@nodeblocks/backend-sdk';
const { mapMatchingErrorToFalse } = primitives;
const { checkEmailIsUniqueInIdentities, AuthenticationConflictError } = blocks;
const checkUnique = mapMatchingErrorToFalse(checkEmailIsUniqueInIdentities, [
AuthenticationConflictError,
]);
const result = await checkUnique(identities, 'taken@example.com');
// Matching conflict: result.isOk() === true, result.value === false
// Other error: result.isErr() === true
// Success: result.isOk() === true, result.value unchanged
Parameters:
fn: Function returningPromise<Result<T, Error>>errorTypes: Block error constructors to convert tofalse(viainstanceof)
Returns: Wrapped function returning Promise<Result<T | boolean, Error>>
RxJS emitter helpers
These helpers are exported from primitives and are intended for filtering and tagging messages in RxJS or WebSocket pipelines.
notFromEmitter
Curried predicate factory. notFromEmitter(emitterId) returns a predicate that accepts object data and returns true when its emitterId is absent or differs from the supplied ID. Non-object values return false.
import { primitives } from '@nodeblocks/backend-sdk';
import { filter } from 'rxjs';
const { notFromEmitter } = primitives;
observable.pipe(filter(notFromEmitter('socket-123')));
markAsFromEmitter
Curried helper that returns a shallow copy of an object with the supplied emitterId field.
import { primitives } from '@nodeblocks/backend-sdk';
const { markAsFromEmitter } = primitives;
const tagged = markAsFromEmitter('socket-123')({ event: 'updated' });
// { event: 'updated', emitterId: 'socket-123' }
🔄 Error-Safe Composition
flatMap
Chains synchronous operations that return a Result type, enabling error-safe composition.
import { primitives } from '@nodeblocks/backend-sdk';
const { flatMap } = primitives;
const processUserData = compose(
validateUserInput,
flatMap(enrichUserData), // Only runs if validation succeeds
flatMap(formatUserData)
);
flatMapAsync
Chains asynchronous operations that return a Result type, enabling error-safe composition.
import { primitives } from '@nodeblocks/backend-sdk';
const { flatMapAsync } = primitives;
const createAndFetchUser = compose(
createUserInDb,
flatMapAsync(fetchUserById), // Only runs if createUserInDb succeeds
lift(orThrow([[UserNotFoundError, 404]], [['context', 'data', 'user']]))
);
withSoftDelete
Converts hard deletes into soft deletes by automatically managing a deletedAt timestamp, providing data safety and audit capabilities.
Purpose: Enables safe data deletion by marking records as deleted rather than removing them, while automatically filtering out soft-deleted records from queries
Database Operations:
- Reads:
find,findOne,countDocumentsautomatically exclude soft-deleted records (wheredeletedAtexists) - Deletes:
deleteOne,deleteManyconvert to updates settingdeletedAt: new Date() - Updates:
updateOne,updateMany,findOneAndUpdaterespect soft delete filters (won't update already deleted records) - Inserts: Unaffected, work normally
Handler Process:
- Input: Any
AsyncRouteHandlerthat uses database collections - Process: Wraps database collections with soft delete proxy that intercepts operations
- Output: Handler that behaves identically but with soft delete semantics
- Data Safety: Deleted records remain in database with
deletedAttimestamp for audit purposes
Parameters:
handler: The async route handler to wrap with soft delete functionality
Returns: AsyncRouteHandler with soft delete semantics applied to all database operations
Usage Examples:
import { primitives } from '@nodeblocks/backend-sdk';
const { withSoftDelete } = primitives;
// Basic usage - wrap any handler for soft delete functionality
const softDeleteHandler = withSoftDelete(originalHandler);
// In route composition
const deleteUserRoute = withRoute({
method: 'DELETE',
path: '/users/:userId',
handler: withSoftDelete(deleteUserHandler)
});
// Works with all database operations automatically
const userOperations = compose(
withSoftDelete(createUserHandler), // Inserts work normally
withSoftDelete(findUserHandler), // Finds exclude soft-deleted users
withSoftDelete(updateUserHandler), // Updates respect soft delete filters
withSoftDelete(deleteUserHandler) // Deletes become soft deletes
);
// Example: Soft delete marks record instead of removing
// Before: db.users.deleteOne({ id: '123' }) — permanent removal
// After: db.users.deleteOne({ id: '123' }) — becomes updateOne with { $set: { deletedAt: new Date() } }
// Example: Queries automatically exclude soft-deleted records
// Before: db.users.find({ active: true })
// After: db.users.find({ active: true }) — merged filter adds deletedAt: { $exists: false }
Benefits:
- Data Safety: Prevents accidental permanent data loss
- Audit Trail: Maintains historical records for compliance
- Recovery: Soft-deleted records can be restored if needed
- API Compatibility: Existing handlers work without modification
- Performance: Minimal overhead, only adds
deletedAtfiltering
Data Recovery Example:
// To restore a soft-deleted record (bypass soft delete wrapper)
const restoreUser = async (payload: RouteHandlerPayload) => {
const { userId } = payload.params.requestParams;
await payload.context.db.users.updateOne(
{ id: userId },
{ $unset: { deletedAt: 1 } } // Remove the deletedAt field
);
return payload;
};
📝 Practical Examples
Multi-Step Data Processing (block pattern)
import { primitives, blocks } from '@nodeblocks/backend-sdk';
const { compose, flatMapAsync, lift, applyPayloadArgs, orThrow } = primitives;
const { getProductById, normalizeProduct, ProductNotFoundBlockError } = blocks;
const getProductHandler = compose(
applyPayloadArgs(
getProductById,
[
['context', 'db', 'products'],
['params', 'requestParams', 'productId'],
],
'product'
),
flatMapAsync(
applyPayloadArgs(normalizeProduct, [['context', 'data', 'product']], 'normalizedProduct')
),
lift(
orThrow(
[[ProductNotFoundBlockError, 404]],
[['context', 'data', 'normalizedProduct']]
)
)
);
Data Transformation Pipeline
const { compose, flatMapAsync, lift, orThrow } = primitives;
const processOrder = compose(
applyPayloadArgs(validateOrderData, [['params', 'requestBody']], 'order'),
flatMapAsync(applyPayloadArgs(checkInventory, [['context', 'data', 'order']], 'inventory')),
flatMapAsync(applyPayloadArgs(createOrder, [['context', 'data', 'order']], 'createdOrder')),
lift(orThrow([[OrderError, 500]], [['context', 'data', 'createdOrder'], 201]))
);
const createOrderRoute = withRoute({
method: 'POST',
path: '/orders',
handler: processOrder,
});
Error-Safe Validation Chain
import { Result, ok, err } from 'neverthrow';
import { primitives } from '@nodeblocks/backend-sdk';
const { flatMap, flatMapAsync, lift } = primitives;
// Step 1: Validate input
const validateUserInput = (data: any): Result<ValidatedUser, ValidationError> => {
if (!data.email || !data.name) {
return err(new ValidationError('Missing required fields'));
}
return ok(data);
};
// Step 2: Check if user exists
const checkUserExists = async (user: ValidatedUser): Promise<Result<User, DatabaseError>> => {
const existing = await db.users.findOne({ email: user.email });
if (existing) {
return err(new DatabaseError('User already exists'));
}
return ok(user);
};
// Step 3: Save user
const saveUser = async (user: ValidatedUser): Promise<Result<User, DatabaseError>> => {
try {
const saved = await db.users.create(user);
return ok(saved);
} catch (error) {
return err(new DatabaseError('Failed to save user'));
}
};
// Compose the error-safe pipeline
const createUserHandler = compose(
(payload) => validateUserInput(payload.params.requestBody),
flatMapAsync(checkUserExists),
flatMapAsync(saveUser),
lift(orThrow([[DatabaseError, 500]], [['context', 'data', 'user'], 201]))
);
📐️️ Best Practices
1. Keep Functions Small and Focused
// ✅ Good: Small, focused functions
const validateEmail = (email: string) => /* validation logic */;
const hashPassword = (password: string) => /* hashing logic */;
const saveUser = (user: User) => /* database logic */;
// ❌ Avoid: Large, multi-purpose functions
const createUserMegaFunction = (data: any) => {
// validation, hashing, saving, emailing all in one function
};
2. Handle Async Operations Properly
// ✅ Good: Use flatMapAsync for async operations
const enrichDataPipeline = compose(
fetchUserData,
flatMapAsync(fetchUserProfile), // Async operation
flatMapAsync(fetchUserSettings), // Another async operation
lift(orThrow([[UserError, 404]], [['context', 'data', 'settings']])) // Terminator
);
// ❌ Avoid: Mixing async/sync without proper utilities
const badPipeline = compose(
fetchUserData,
fetchUserProfile, // This won't work properly in composition
formatResponse
);
3. Compose at the Right Level
// ✅ Good: Compose related operations
const userRegistrationFlow = compose(
validateRegistration,
createUser,
sendWelcomeEmail
);
// ✅ Good: Keep unrelated operations separate
const userLoginFlow = compose(
validateCredentials,
authenticateUser,
generateToken
);
4. Use Result Types for Error Handling
// ✅ Good: Explicit error handling with Result types
const safeOperation = compose(
validateInput,
flatMapAsync(databaseOperation), // Only runs if validation succeeds
lift(orThrow([[DatabaseError, 500]], [['context', 'data', 'result']]))
);
// ❌ Avoid: Relying on thrown exceptions in composition
const unsafeOperation = compose(
validateInput,
databaseOperation, // Might throw, breaking composition
formatResponse
);
🔗 See Also
- Handler Wrappers - Cross-cutting concerns like logging and pagination
- Handler Component - Basic handler concepts
- Route Component - Route definitions
- Error Handling - Result types and error patterns
- Functional Programming - Core composition concepts