🔧 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
compose
Combines multiple functions into a single pipeline, executing them from right to left.
import { primitives } from '@nodeblocks/backend-sdk';
const { compose } = primitives;
const processUser = compose(
validateUser,
saveUser,
formatResponse
);
// Equivalent to: formatResponse(saveUser(validateUser(input)))
lift
Lifts a regular function into a payload context.
import { primitives } from '@nodeblocks/backend-sdk';
const { lift } = primitives;
const normalizeUser = (user: User) => {
const { _id, ...normalized } = user;
return normalized;
};
// Lift the function to work in composition
const handler = compose(
getUserFromDb,
lift(normalizeUser) // Now works with payload results
);
mergeData
Merges data into the payload context for use by subsequent handlers in a composition pipeline.
import { handlers } from '@nodeblocks/backend-sdk';
const { mergeData } = handlers;
const enrichUserData = async (payload: RouteHandlerPayload) => {
const profile = await profileService.getProfile(payload.context.data.userId);
return mergeData(payload, { profile });
};
const getUserHandler = compose(
fetchUserFromDb,
flatMapAsync(enrichUserData), // profile is now available in payload.context.data
lift(formatUserResponse)
);
Parameters:
payload
: The current payload objectdata
: Object containing data to merge intopayload.context.data
Returns: Updated payload object with merged data
Usage Examples:
// Merge single value
return mergeData(payload, { userId: '123' });
// Merge multiple values
return 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 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:
RouteHandlerPayload
with 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 function result in payload
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(normalizeItemTerminator)
)
});
// 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:
Result
containingRouteHandlerPayload
orError
- Process: Maps specific error types to HTTP status codes or custom errors, optionally extracts data from success payload
- Output: Success data or throws mapped error with appropriate status code
- Errors: Throws
NodeblocksError
with 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
and returns success data or throws mapped error
🔎 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([]); // false
hasValue({ a: 1 }); // true
Usage Examples:
import { primitives } from '@nodeblocks/backend-sdk';
const { 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]],
[['user'], 200]
))
)
});
🔄 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(normalizeUserResponse)
);
📝 Practical Examples
Multi-Step Data Processing
import { primitives } from '@nodeblocks/backend-sdk';
const { compose, flatMapAsync, lift } = primitives;
// Step 1: Enrich user data
const enrichUserData = async (payload: RouteHandlerPayload) => {
const profile = await profileService.getProfile(payload.context.data.userId);
return mergeData(payload, { profile });
};
// Step 2: Format data for response
const formatUserResponse = (payload: RouteHandlerPayload) => {
const { user, profile } = payload.context.data;
return { ...user, profile };
};
// Compose the pipeline
const getUserHandler = compose(
fetchUserFromDb,
flatMapAsync(enrichUserData),
lift(formatUserResponse)
);
Data Transformation Pipeline
const processOrder = compose(
validateOrderData,
lift(calculateTotals),
flatMapAsync(checkInventory),
flatMapAsync(createOrder),
lift(formatOrderResponse)
);
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(formatUserResponse)
);
📐️️ 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(formatResponse) // Sync transformation
);
// ❌ 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(formatResponse)
);
// ❌ 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