Composition Guide
Build complex transformations by combining simple functions.
Overview
Function composition is about creating new functions by combining existing ones. The Function module provides four powerful composition utilities: compose, converge, juxt, and ap.
compose - Right-to-Left Composition
The compose function applies functions from right to left (mathematical composition).
Signature
function compose<A, B>(fn1: (a: A) => B): (a: A) => B
function compose<A, B, C>(fn2: (b: B) => C, fn1: (a: A) => B): (a: A) => C
// ... up to 5 functionsBasic Usage
import { compose } from '@ontologyio/receta/function'
const addOne = (n: number) => n + 1
const double = (n: number) => n * 2
const square = (n: number) => n * n
const f = compose(square, double, addOne)
f(2) // => square(double(addOne(2))) => square(double(3)) => square(6) => 36When to Use compose vs pipe
import { pipe } from 'remeda'
// compose: right-to-left (mathematical notation)
const f = compose(c, b, a) // c(b(a(x)))
// pipe: left-to-right (more intuitive for data pipelines)
const g = pipe(x, a, b, c) // c(b(a(x)))
// Prefer pipe for data transformation
const result = pipe(
data,
transform,
validate,
save
)
// Use compose when thinking mathematically
const tidy = compose(finalize, clean, prepare)Real-World Example
const exclaim = (s: string) => `${s}!`
const toUpper = (s: string) => s.toUpperCase()
const trim = (s: string) => s.trim()
const shout = compose(exclaim, toUpper, trim)
shout(' hello world ') // => 'HELLO WORLD!'converge - Apply Multiple Functions, Combine Results
The converge combinator applies multiple functions to the same input, then passes all results to a combining function.
Signature
function converge<T, Args extends readonly unknown[], R>(
after: (...args: Args) => R,
fns: FunctionTuple<T, Args>
): (value: T) => RBasic Usage
TypeScript can't infer the input type from the list of functions, so a standalone converge needs its type arguments: the input, the results of each function, and the final result. Inside a pipe, the input type comes from the pipeline and they can be left out.
import { converge } from '@ontologyio/receta/function'
// Calculate average
const average = converge<number[], [number, number], number>(
(sum, length) => sum / length,
[
(nums) => nums.reduce((a, b) => a + b, 0),
(nums) => nums.length
]
)
average([1, 2, 3, 4, 5]) // => 3When to Use converge
Use converge when you need to:
- Derive multiple values from a single input
- Build objects from different transformations
- Combine statistics or metrics
// Building summary objects
const buildSummary = converge<number[], [number, number, number, number], Record<string, number>>(
(total, count, max, min) => ({
total,
count,
average: total / count,
max,
min,
range: max - min
}),
[
(nums: number[]) => nums.reduce((a, b) => a + b, 0),
(nums: number[]) => nums.length,
(nums: number[]) => Math.max(...nums),
(nums: number[]) => Math.min(...nums)
]
)
buildSummary([1, 5, 3, 9, 2])
// => { total: 20, count: 5, average: 4, max: 9, min: 1, range: 8 }Real-World Example: Profile Building
interface User {
firstName: string
lastName: string
email: string
scores: number[]
}
interface Profile {
fullName: string
username: string
domain: string
stats: {
totalScore: number
averageScore: number
maxScore: number
}
}
const buildProfile = converge<User, [string, string, string, any], Profile>(
(fullName, username, domain, stats) => ({
fullName,
username,
domain,
stats
}),
[
(user: User) => `${user.firstName} ${user.lastName}`,
(user: User) => user.email.split('@')[0]!,
(user: User) => user.email.split('@')[1]!,
(user: User) => {
const total = user.scores.reduce((a, b) => a + b, 0)
const avg = total / user.scores.length
return {
totalScore: total,
averageScore: Math.round(avg * 10) / 10,
maxScore: Math.max(...user.scores)
}
}
]
)
const user: User = {
firstName: 'Alice',
lastName: 'Johnson',
email: 'alice.johnson@example.com',
scores: [92, 88, 95, 87, 91]
}
console.log(buildProfile(user))
// => {
// fullName: 'Alice Johnson',
// username: 'alice.johnson',
// domain: 'example.com',
// stats: { totalScore: 453, averageScore: 90.6, maxScore: 95 }
// }juxt - Apply Functions in Parallel
The juxt combinator applies multiple functions to the same input and collects all results in an array.
Signature
function juxt<Fns extends readonly ((...args: any[]) => any)[]>(
fns: Fns
): (...args: Parameters<Fns[0]>) => ReturnTypes<Fns>Basic Usage
import { juxt } from '@ontologyio/receta/function'
const analyze = juxt([
(nums: number[]) => nums.length,
(nums: number[]) => Math.min(...nums),
(nums: number[]) => Math.max(...nums),
(nums: number[]) => nums.reduce((a, b) => a + b, 0) / nums.length
])
analyze([1, 2, 3, 4, 5])
// => [5, 1, 5, 3]
// [length, min, max, average]Difference Between juxt and converge
const sum = (nums: number[]) => nums.reduce((a, b) => a + b, 0)
const count = (nums: number[]) => nums.length
const max = (nums: number[]) => Math.max(...nums)
const data = [1, 2, 9, 3, 0]
// juxt: returns array of results
const metrics = juxt([sum, count, max])
metrics(data) // => [15, 5, 9]
// converge: combines results with a function
const summary = converge<number[], [number, number, number], Record<string, number>>(
(sum, count, max) => ({ sum, count, max }),
[sum, count, max]
)
summary(data) // => { sum: 15, count: 5, max: 9 }Real-World Example: Data Analytics
interface SalesRecord {
id: string
amount: number
region: string
}
const extractMetrics = juxt([
(records: SalesRecord[]) => records.length,
(records: SalesRecord[]) => records.reduce((sum, r) => sum + r.amount, 0),
(records: SalesRecord[]) => Math.max(...records.map(r => r.amount)),
(records: SalesRecord[]) => Math.min(...records.map(r => r.amount)),
(records: SalesRecord[]) => new Set(records.map(r => r.region)).size
])
const sales: SalesRecord[] = [
{ id: '1', amount: 1200, region: 'North' },
{ id: '2', amount: 800, region: 'South' },
{ id: '3', amount: 1500, region: 'North' },
{ id: '4', amount: 950, region: 'East' },
{ id: '5', amount: 2000, region: 'West' }
]
const [count, total, max, min, uniqueRegions] = extractMetrics(sales)
console.log({ count, total, max, min, uniqueRegions })
// => { count: 5, total: 6450, max: 2000, min: 800, uniqueRegions: 4 }ap - Applicative Apply
The ap combinator applies an array of functions to an array of values, creating all possible combinations.
Signature
function ap<T, U>(
fns: readonly ((value: T) => U)[]
): (values: readonly T[]) => U[]Basic Usage
import { ap } from '@ontologyio/receta/function'
const fns = [
(n: number) => n + 1,
(n: number) => n * 2,
(n: number) => n * n
]
ap(fns, [1, 2, 3])
// => [
// 2, 3, 4, // +1 to each
// 2, 4, 6, // *2 to each
// 1, 4, 9 // square each
// ]When to Use ap
The ap function is less commonly used than converge or juxt, but useful for:
- Applying multiple validators to multiple inputs
- Testing multiple transformations
- Cross-product operations
Real-World Example: Validation Matrix
const validators = [
(s: string) => s.length > 0,
(s: string) => s.length < 100,
(s: string) => /^[a-z]+$/i.test(s)
]
const inputs = ['hello', '', 'test123']
const results = ap(validators, inputs)
// => [
// true, false, true, // length > 0
// true, true, true, // length < 100
// true, false, false // only letters
// ]
// Reshape into validation matrix
const matrix = inputs.map((input, i) => ({
input,
validations: {
notEmpty: results[i],
notTooLong: results[inputs.length + i],
lettersOnly: results[inputs.length * 2 + i]
}
}))Combining Composition Functions
You can nest these combinators for powerful transformations:
import { converge, juxt } from '@ontologyio/receta/function'
type Analysis = { stats: Record<string, number>; uniqueCount: number }
// Extract multiple metrics, then build summary
const analyzeData = converge<number[], [readonly number[], number], Analysis>(
(metrics, uniqueValues) => ({
stats: {
count: metrics[0],
sum: metrics[1],
avg: metrics[1] / metrics[0],
max: metrics[2],
min: metrics[3]
},
uniqueCount: uniqueValues
}),
[
juxt([
(data: number[]) => data.length,
(data: number[]) => data.reduce((a, b) => a + b, 0),
(data: number[]) => Math.max(...data),
(data: number[]) => Math.min(...data)
]),
(data: number[]) => new Set(data).size
]
)
analyzeData([1, 2, 2, 3, 3, 3, 4, 5])
// => {
// stats: { count: 8, sum: 23, avg: 2.875, max: 5, min: 1 },
// uniqueCount: 5
// }Best Practices
1. Name Your Functions
// ❌ Unclear
const fn = converge<Order, [number, number], number>((a, b) => a + b, [f1, f2])
// ✅ Clear intent
const calculateTotal = converge<Order, [number, number], number>(
(subtotal, tax) => subtotal + tax,
[getSubtotal, calculateTax]
)2. Keep Functions Small
// ✅ Small, focused functions
const getFullName = (user: User) => `${user.firstName} ${user.lastName}`
const getUsername = (user: User) => user.email.split('@')[0]!
const getDomain = (user: User) => user.email.split('@')[1]!
const buildProfile = converge<User, [string, string, string], Profile>(
(name, username, domain) => ({ name, username, domain }),
[getFullName, getUsername, getDomain]
)3. Type Your Combinators
// ✅ Explicit types help readability
const buildSummary = converge<
number[],
[number, number, number, number],
Summary
>(
(total, count, max, min): Summary => ({
total, count, max, min, avg: total / count
}),
[
(nums) => R.sum(nums),
(nums) => nums.length,
(nums) => Math.max(...nums),
(nums) => Math.min(...nums)
]
)Next Steps
- Partial Application Guide - Learn partial, flip, arity
- Control Flow Guide - Explore tap, tryCatch, memoize
- API Reference - Complete function signatures