๐งฎ Functional Programming Concepts
Nodeblocks backend SDK is built on functional programming principles. Understanding these mathematical concepts will help you write more elegant, composable, and maintainable code.
๐ What is Functional Programming?โ
Functional programming is a programming paradigm that treats computation as the evaluation of mathematical functions. In Nodeblocks, we use functional programming to:
- Compose complex operations from simple functions
- Minimize mutable state; isolate side effects (DB I/O, logging) in blocks and handlers
- Create predictable, testable code
- Build modular, reusable components
๐ Core Mathematical Conceptsโ
Function Compositionโ
Function composition combines multiple functions into a single pipeline. In the SDK, compose is an alias for Ramda's pipe โ functions run left to right:
compose(f, g, h)(x) = h(g(f(x)))
In Nodeblocks:
import { primitives } from '@nodeblocks/backend-sdk';
const { compose } = primitives;
// Instead of nested calls:
const result = h(g(f(x)));
// We use a pipeline:
const pipeline = compose(f, g, h);
const result = pipeline(x);
SDK Example โ feature composition:
import { primitives, routes, schemas } from '@nodeblocks/backend-sdk';
const { compose } = primitives;
const { registerCredentialsSchema } = schemas;
const { registerCredentialsRoute } = routes;
// Features are composed left to right: schema โ route
const registerCredentialsFeature = compose(
registerCredentialsSchema, // 1. OpenAPI schema registration
registerCredentialsRoute, // 2. Route definition with handler chain
);
SDK Example โ handler chain (recommended block pattern):
Routes compose blocks with applyPayloadArgs, chain Results via flatMapAsync, and terminate with lift(orThrow(...)). This matches getProductRoute in the SDK product routes:
import { primitives, blocks } from '@nodeblocks/backend-sdk';
const { compose, flatMapAsync, lift, applyPayloadArgs, orThrow, withRoute } = primitives;
const {
getProductById,
normalizeImagesOfProduct,
normalizeProduct,
ProductNotFoundBlockError,
FileStorageServiceError,
} = blocks;
export const getProductRoute = withRoute({
method: 'GET',
path: '/products/:productId',
handler: compose(
applyPayloadArgs(
getProductById,
[
['context', 'db', 'products'],
['params', 'requestParams', 'productId'],
],
'product'
),
flatMapAsync(
applyPayloadArgs(
normalizeImagesOfProduct,
[
['context', 'fileStorageDriver'],
['context', 'data', 'product'],
],
'productWithNormalizedImages'
)
),
flatMapAsync(
applyPayloadArgs(
normalizeProduct,
[['context', 'data', 'productWithNormalizedImages']],
'normalizedProduct'
)
),
lift(
orThrow(
[
[ProductNotFoundBlockError, 404],
[FileStorageServiceError, 500],
],
[['context', 'data', 'normalizedProduct']]
)
)
),
});
Legacy note: Some services (e.g. profile CRUD) still use handlers that return
ResultwithNodeblocksErrorinstead of block errors. Those routes useflatMapAsync(handlerFn)withoutapplyPayloadArgsand often pass an empty error map toorThrow. See Route ยป Legacy: Composing with Handlers.
Logging: Production routes often wrap handlers and blocks with
withLogging(see Handler Wrappers ยป withLogging). ThecreateProductRouteexample below includes it; read-only routes likegetProductRoutemay omit it.
Benefits:
- Readable: Functions flow from left to right
- Composable: Easy to add/remove steps
- Testable: Each function can be tested independently
Curryingโ
Currying is the technique of converting a function that takes multiple arguments into a sequence of functions that each take a single argument.
Mathematical Definition:
f(x, y, z) โ f(x)(y)(z)
In Nodeblocks (Ramda):
The SDK uses Ramda for currying and partial application. This example shows the general technique โ not an SDK export:
import { curry } from 'ramda';
// Regular function
const add = (a, b) => a + b;
// Curried function
const curriedAdd = curry(add);
const addFive = curriedAdd(5);
const result = addFive(3); // 8
SDK Example โ validator factories:
Validators are higher-order functions that return (payload) => Promise<void>. Factory functions like isAuthenticated() and checkIdentityType(['admin']) are partially applied at route definition time:
import { primitives, validators } from '@nodeblocks/backend-sdk';
const { withRoute } = primitives;
const { isAuthenticated, checkIdentityType } = validators;
// isAuthenticated() is a factory โ call it once to get a validator
const protectedRoute = withRoute({
method: 'GET',
path: '/secret',
validators: [isAuthenticated()],
handler: secretHandler,
});
Benefits:
- Partial Application: Create specialized functions
- Reusability: Same function, different configurations
- Composability: Easy to combine with other functions
Partial Applicationโ
Partial application is the process of fixing a number of arguments to a function, producing another function of smaller arity.
Mathematical Definition:
f(x, y, z) โ f(x, y, _) โ g(z)
In Nodeblocks (Ramda):
The SDK uses Ramda's partial for service wiring. This example shows the general technique:
import { partial } from 'ramda';
// Original function
const multiply = (a, b) => a * b;
// Partially apply first argument
const multiplyByTwo = partial(multiply, [2]);
const result = multiplyByTwo(5); // 10
SDK Example โ service configuration:
Services are built with defService(partial(compose(...features), [serviceContext])). The service context โ authenticate, configuration, dataStores, and optional drivers โ is fixed once at service creation:
import express from 'express';
import { partial, compose } from 'ramda';
import { services, drivers, middlewares } from '@nodeblocks/backend-sdk';
const { authService } = services;
const { withMongo } = drivers;
const { nodeBlocksErrorMiddleware } = middlewares;
const connectToDatabase = withMongo(
'mongodb://localhost:27017/?authSource=admin',
'dev',
'user',
'password'
);
// authService internally uses:
// defService(partial(compose(registerCredentialsFeature, loginWithCredentialsFeature, ...), [{
// authenticate, configuration, dataStores, mailService, googleOAuthDriver, ...
// }]))
express()
.use(
authService(
{
...(await connectToDatabase('identities')),
...(await connectToDatabase('onetimetokens')),
},
{
authSecrets: {
authEncSecret: 'your-encryption-secret',
authSignSecret: 'your-signing-secret',
},
authMode: 'bearer',
identity: { typeIds: { admin: '100', guest: '000', regular: '001' } },
},
{
// mailService, // optional โ required for email verification / MFA flows
}
)
)
.use(nodeBlocksErrorMiddleware())
.listen(8089, () => console.log('Server running'));
Benefits:
- Configuration: Set up functions with default parameters
- Flexibility: Same function, different configurations
- Cleaner Code: Less repetition
Higher-Order Functionsโ
Higher-order functions are functions that either take functions as arguments or return functions as results.
Mathematical Definition:
H(f) = g, where f and g are functions
In Nodeblocks:
import { primitives } from '@nodeblocks/backend-sdk';
// Validator factory: returns a function that receives the full route payload
const createEmailValidator = (errorMessage: string): primitives.Validator => {
return async ({ params }) => {
if (!params.requestBody?.email?.includes('@')) {
throw new primitives.NodeblocksError(400, errorMessage);
}
};
};
// Usage
const requireEmail = createEmailValidator('Email is required');
SDK Example โ route factory:
withRoute is itself a higher-order function. A route factory wraps it to reduce repetition. The return value is a RouteComposer used inside defService, not a standalone Express route:
import { primitives, validators } from '@nodeblocks/backend-sdk';
const { withRoute } = primitives;
const { isAuthenticated } = validators;
const createPostRoute = (path: string, handler, validators = []) => {
return withRoute({
method: 'POST',
path,
handler,
validators,
});
};
// Usage inside a service feature composition
const createProfileRoute = createPostRoute('/profiles', createProfileHandler, [isAuthenticated()]);
๐ง Nodeblocks Functional Patternsโ
Result Types (Monads)โ
Nodeblocks uses neverthrow Result types inside blocks and handler chains to handle success and failure cases explicitly. Blocks return Promise<Result<T, SpecificBlockError>>; applyPayloadArgs merges successful values into payload.context.data.
neverthrow basics (used inside blocks):
import { ok, err } from 'neverthrow';
const validateEmail = (email: string) => {
if (!email) {
return err(new Error('Email required'));
}
return ok(email);
};
SDK handler chain pattern:
Create routes combine a handler (write) with blocks (read back + normalize). The chain terminates with lift(orThrow(...)), mapping block errors to HTTP status codes. This matches createProductRoute in the SDK product routes:
import { primitives, blocks, handlers, validators } from '@nodeblocks/backend-sdk';
const { compose, flatMapAsync, lift, applyPayloadArgs, orThrow, withRoute, withLogging } = primitives;
const { createProduct } = handlers;
const {
getProductById,
normalizeProduct,
ProductNotFoundBlockError,
FileStorageServiceError,
} = blocks;
const { isAuthenticated, checkIdentityType } = validators;
export const createProductRoute = withRoute({
method: 'POST',
path: '/products',
validators: [isAuthenticated(), checkIdentityType(['admin'])],
handler: compose(
withLogging(createProduct),
flatMapAsync(
applyPayloadArgs(
getProductById,
[
['context', 'db', 'products'],
['context', 'data', 'productId'],
],
'product'
)
),
flatMapAsync(
applyPayloadArgs(
normalizeProduct,
[['context', 'data', 'product']],
'normalizedProduct'
)
),
lift(
orThrow(
[
[ProductNotFoundBlockError, 404],
[FileStorageServiceError, 500],
],
[['context', 'data', 'normalizedProduct']]
)
)
),
});
Function Liftingโ
In the SDK, lift adapts the terminator step of a handler chain. 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 } from '@nodeblocks/backend-sdk';
const { compose, flatMapAsync, lift, applyPayloadArgs, orThrow } = primitives;
// lift wraps orThrow โ the standard terminator pattern
handler: compose(
applyPayloadArgs(someBlock, [/* paths */], 'result'),
flatMapAsync(applyPayloadArgs(anotherBlock, [/* paths */], 'nextResult')),
lift(orThrow([[SomeBlockError, 404], [SomeBlockError, 500]], [['context', 'data', 'nextResult'], 200])),
)
lift does not lift plain functions into the Result functor. It bridges the async Promise between composed handler steps and the synchronous terminator.
Pipeline Compositionโ
A complete route handler pipeline follows this pattern: blocks/handlers โ flatMapAsync chains โ lift(orThrow) terminator.
See the getProductRoute example above for a full SDK route. Step-by-step:
- Fetch โ
applyPayloadArgs(block, paths, key)runs a block and stores the result inpayload.context.data - Transform โ
flatMapAsync(applyPayloadArgs(...))chains additional blocks when the previous step succeeds - Terminate โ
lift(orThrow(errorMap, successMap))maps block errors to HTTP status codes and extracts response data
Write routes (e.g. createProductRoute) add a handler as the first step, often wrapped with withLogging.
๐งฎ Mathematical Foundationsโ
Category Theoryโ
Nodeblocks patterns are inspired by category theory concepts:
Functors (neverthrow):
import { ok } from 'neverthrow';
// Result is a functor โ it can be mapped over
const userResult = ok({ name: 'John', email: 'john@example.com' });
const formattedResult = userResult.map(user => ({
...user,
displayName: user.name.toUpperCase()
}));
Monads (neverthrow):
import { ok } from 'neverthrow';
// Result is a monad โ it can be chained
const result = ok(5)
.andThen(x => ok(x * 2))
.andThen(x => ok(x + 1));
// Result: ok(11)
๐ Best Practicesโ
1. Pure Functionsโ
- Functions should have no side effects
- Same input always produces same output
- Easy to test and reason about
// โ
Pure function
const add = (a, b) => a + b;
// โ Impure function (side effect)
const addAndLog = (a, b) => {
console.log('Adding:', a, b); // Side effect
return a + b;
};
2. Immutabilityโ
- Don't modify existing data
- Create new data structures instead
// โ
Immutable
const updateUser = (user, updates) => ({
...user,
...updates
});
// โ Mutable
const updateUser = (user, updates) => {
Object.assign(user, updates); // Modifies original
return user;
};
3. Function Compositionโ
- Build complex operations from simple functions
- Keep functions focused and single-purpose
import { primitives, blocks } from '@nodeblocks/backend-sdk';
const { compose, flatMapAsync, lift, applyPayloadArgs, orThrow } = primitives;
// โ
Composed handler chain
const handler = compose(
applyPayloadArgs(someBlock, [/* paths */], 'result'),
flatMapAsync(applyPayloadArgs(nextBlock, [/* paths */], 'next')),
lift(orThrow([[BlockError, 404]], [['context', 'data', 'next'], 200])),
);
// โ Monolithic
const handler = async (payload) => {
// 100 lines of mixed concerns
};
4. Error Handling with Resultsโ
- Blocks return
Resultfor expected errors;orThrowmaps them to HTTP responses - Validators throw
NodeblocksErrorfor request-level rejections - Include every block error you expect in the
orThrowerror map โ unmapped errors (e.g.ProductUnexpectedDBErrorfromgetProductById) propagate as unhandled failures and become 500 responses via error middleware (see Error Handling)
import { ok, err } from 'neverthrow';
// โ
Inside a block โ explicit Result
const findProfile = async (db, id) => {
const profile = await db.profiles.findOne({ id });
if (!profile) {
return err(new ProfileNotFoundBlockError('Profile not found'));
}
return ok(profile);
};
// โ
At route level โ orThrow handles Result โ HTTP mapping
lift(orThrow([[ProfileNotFoundBlockError, 404]], [['context', 'data', 'profile'], 200]))
// โ Mixing throw and Result inconsistently in the same layer
โก๏ธ Nextโ
- Learn about Route Composition to see these concepts in action
- Explore Composition Utilities for
compose,lift,flatMapAsync, andapplyPayloadArgsreference - Explore Service Patterns for functional service design
- Check out Validators for functional validation patterns