Skip to main content
Version: 0.14.0 (Latest)

๐Ÿ”ง 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โ€‹