Skip to main content
Version: 0.5.0 (Previous)

🔧 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 object
  • data: Object containing data to merge into payload.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 containing RouteHandlerPayload or Error
  • 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 mapping
  • successMap: 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