Skip to main content
Version: ๐Ÿšง Canary

๐Ÿ”ง 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 object
  • data: Object containing data to merge into payload.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: 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 the function result in payload.context.data[key] (via mergeData)

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: Result containing RouteHandlerPayload or Error
  • 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 original RouteHandlerPayload. Throws mapped errors with appropriate status codes on failure.
  • Errors: Throws NodeblocksError with status code or custom error instances

Parameters:

  • errorMap: Array of [CustomError, HttpStatusCode | Error] tuples for error mapping
  • successMap: 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 returning Promise<Result<T, Error>>
  • errorTypes: Block error constructors to convert to false (via instanceof)

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, countDocuments automatically exclude soft-deleted records (where deletedAt exists)
  • Deletes: deleteOne, deleteMany convert to updates setting deletedAt: new Date()
  • Updates: updateOne, updateMany, findOneAndUpdate respect soft delete filters (won't update already deleted records)
  • Inserts: Unaffected, work normally

Handler Process:

  • Input: Any AsyncRouteHandler that 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 deletedAt timestamp 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 deletedAt filtering

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โ€‹