Skip to main content
Version: 0.14.0 (Latest)

๐Ÿงฎ 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 Result with NodeblocksError instead of block errors. Those routes use flatMapAsync(handlerFn) without applyPayloadArgs and often pass an empty error map to orThrow. See Route ยป Legacy: Composing with Handlers.

Logging: Production routes often wrap handlers and blocks with withLogging (see Handler Wrappers ยป withLogging). The createProductRoute example below includes it; read-only routes like getProductRoute may 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('refreshtokens')),
...(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:

  1. Fetch โ€” applyPayloadArgs(block, paths, key) runs a block and stores the result in payload.context.data
  2. Transform โ€” flatMapAsync(applyPayloadArgs(...)) chains additional blocks when the previous step succeeds
  3. 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 Result for expected errors; orThrow maps them to HTTP responses
  • Validators throw NodeblocksError for request-level rejections
  • Include every block error you expect in the orThrow error map โ€” unmapped errors (e.g. ProductUnexpectedDBError from getProductById) 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โ€‹