| name | composable-functions |
| description | Work with composable-functions library for type-safe business logic. Use when working with applySchema, withContext, pipe, sequence, business functions, context validation, input validation, or when user mentions composable functions, schemas, or error handling patterns. |
Composable Functions
Work with the composable-functions library (v5.0.0) for building type-safe, composable business logic.
Overview
Composable functions provide a functional programming approach to building robust business logic with:
- Type safety: Full TypeScript support with type inference
- Error handling: Structured error types (InputError, ContextError)
- Composition: Combinators like pipe, sequence, all, collect
- Schema validation: Runtime validation with Zod or other @standard-schema libraries
- Context passing: Automatic context forwarding for authorization
Core Types
Composable
A function that returns Promise<Result<T>>:
import { composable } from 'composable-functions'
const add = composable((a: number, b: number) => a + b)
Result
Union type representing success or failure:
type Result<T> = Success<T> | Failure
{
success: true,
data: T,
errors: []
}
{
success: false,
errors: Error[]
}
Always check success before accessing data:
const result = await fn()
if (!result.success) {
return
}
Error Types
InputError
Validation errors for user input:
import { InputError } from 'composable-functions'
throw new InputError('Required field', ['email'])
ContextError
Authorization or environment errors:
import { ContextError } from 'composable-functions'
throw new ContextError('Unauthorized', ['currentUser', 'role'])
ErrorList
Group multiple errors:
import { ErrorList, InputError, ContextError } from 'composable-functions'
throw new ErrorList([
new InputError('Required', ['name']),
new ContextError('Forbidden', ['user'])
])
Schema Validation with applySchema
Use applySchema to validate inputs and context at runtime:
import { applySchema } from 'composable-functions'
import { z } from 'zod'
const fn = applySchema(
z.object({ id: z.string() }),
z.object({ currentUser: userSchema })
)(({ id }, context) => {
return db.find(id)
})
In this repo, use context schemas from auth.server:
import { applySchema } from 'composable-functions'
import { userContextSchema } from '~/business/auth.server'
import { z } from 'zod'
const reviseUser = applySchema(
z.object({ userId: z.string(), name: z.string() }),
userContextSchema
)(async ({ userId, name }, context) => {
return db.insertInto('userRevisions').values({ userId, name }).execute()
})
Composition Combinators
pipe
Sequential composition (left to right):
import { pipe } from 'composable-functions'
const add = (a: number, b: number) => a + b
const double = (n: number) => n * 2
const addAndDouble = pipe(add, double)
const result = await addAndDouble(2, 3)
sequence
Like pipe, but returns all intermediate results:
import { sequence } from 'composable-functions'
const a = (n: number) => String(n)
const b = (s: string) => s === '1'
const fn = sequence(a, b)
const result = await fn(1)
all
Run functions in parallel with same inputs:
import { all } from 'composable-functions'
const add = (a: number, b: number) => a + b
const mul = (a: number, b: number) => a * b
const fn = all(add, mul)
const result = await fn(2, 3)
collect
Like all, but with named results:
import { collect } from 'composable-functions'
const sum = (a: number, b: number) => a + b
const product = (a: number, b: number) => a * b
const fn = collect({ sum, product })
const result = await fn(2, 3)
branch
Conditional execution:
import { branch } from 'composable-functions'
const getIdOrEmail = (data: { id?: number, email?: string }) =>
data.id ?? data.email
const findById = (id: number) => db.users.find({ id })
const findByEmail = (email: string) => db.users.find({ email })
const findUser = branch(
getIdOrEmail,
(idOrEmail) => typeof idOrEmail === 'number' ? findById : findByEmail
)
map
Transform successful output:
import { map } from 'composable-functions'
const add = (a: number, b: number) => a + b
const addAndFormat = map(add, (sum) => `Result: ${sum}`)
const result = await addAndFormat(2, 3)
Working with Context
The withContext namespace provides combinators that automatically pass context through compositions:
withContext.pipe
import { withContext } from 'composable-functions'
const a = (str: string, ctx: { user: User }) => str === '1'
const b = (bool: boolean, ctx: { user: User }) => bool && ctx.user.admin
const fn = withContext.pipe(a, b)
const result = await fn('1', { user: { admin: true } })
withContext.sequence
import { withContext } from 'composable-functions'
const a = (n: number, ctx: { user: User }) => String(n)
const b = (s: string, ctx: { user: User }) => s === '1'
const fn = withContext.sequence(a, b)
const result = await fn(1, { user: { admin: true } })
withContext.branch
import { withContext } from 'composable-functions'
const checkAdmin = (data: any, ctx: { user: User }) => ctx.user.admin
const adminAction = (data: any, ctx: { user: User }) => 'admin'
const userAction = (data: any, ctx: { user: User }) => 'user'
const fn = withContext.branch(
checkAdmin,
(isAdmin) => isAdmin ? adminAction : userAction
)
Application Patterns
The three-layer architecture (components → loaders/actions → business functions) and the context-schema hierarchy live in the authorization skill — load it for those patterns.
Route schemas vs business schemas
The convention across the codebase: <name>FormSchema — only the fields the form actually renders — for the route's act()/SchemaForm, and <name>Schema — form fields plus ids — for the business function's applySchema(). The action supplies the ids from params.
act() resolves the mutation input with a pinned precedence: an explicitly submitted form field wins; a schema field the form didn't submit falls back to the route param, then the search param; a coerced default (an unchecked checkbox's false) survives only when no param shares its name. The contract lives in app/framework/controllers/act.server.test.ts — read those tests before relying on subtler interactions, and extend them when changing act().
remix-forms sharp edges
- A
z.preprocess wrapper hides .optional() from remix-forms' shape introspection, wrongly marking the field required — use plain .optional().
- The checkbox schema coerces only the strings
'true'/'on'; a JS boolean true in a test payload silently becomes false.
- Every guard/validation
InputError needs an explicit field path — a pathless error (or one under a key the form doesn't render) displays nothing through act(), making the failure invisible to the user.
Error Handling
Check error types
import { isInputError, isContextError } from 'composable-functions'
const result = await fn(input)
if (!result.success) {
const inputErrors = result.errors.filter(isInputError)
const contextErrors = result.errors.filter(isContextError)
}
Transform errors
import { mapErrors } from 'composable-functions'
const withCustomErrors = mapErrors(fn, (errors) =>
errors.map(e => e.message.includes('Not found')
? new NotFoundError()
: e
)
)
Catch failures
import { catchFailure } from 'composable-functions'
const optional = catchFailure(fn, (errors, ...args) => {
console.log('Failed:', errors)
return null
})
Utilities
fromSuccess
Unwrap successful result or throw errors:
import { fromSuccess } from 'composable-functions'
const fn = composable(async (id: string) => {
const user = await fromSuccess(getUser)(id)
return { user, extra: 'data' }
})
success / failure
Create results manually:
import { success, failure } from 'composable-functions'
return success({ data: 'value' })
return failure([new Error('Something wrong')])
serialize / serializeError
Make results JSON-safe:
import { serialize } from 'composable-functions'
const serialized = JSON.stringify(serialize(result))
Form Input Helpers
Extract structured data from web requests:
import {
inputFromForm,
inputFromFormData,
inputFromUrl,
inputFromSearch,
} from 'composable-functions'
const formData = await inputFromForm(request)
const queryParams = inputFromUrl(request)
Common Patterns
Validate and transform
const fn = pipe(
applySchema(inputSchema, contextSchema)(validateAndParse),
map(transformData),
applySchema(outputSchema)(finalValidation)
)
Parallel data fetching
const fetchAll = collect({
user: getUser,
posts: getPosts,
comments: getComments,
})
const result = await fetchAll({ userId: '123' })
Conditional authorization
const fn = applySchema(inputSchema, userContextSchema)(
async (input, context) => {
if (!isAdmin(context.currentUser)) {
throw new ContextError('Admin only', ['currentUser', 'role'])
}
return performAdminAction(input)
}
)
Complete Documentation
For the full API reference, migration guides, and all code examples, see references/complete-docs.md.
Best Practices
- Always validate context: Use context schemas with
applySchema
- Check success before data access: TypeScript enforces this
- Use specific error types: InputError for user input, ContextError for authorization
- Prefer composition over nesting: Use combinators instead of manual composition
- Keep functions focused: One composable = one responsibility
- Use withContext for context-heavy flows: Simplifies passing context through pipelines
- Test with fromSuccess: Unwrap results in tests for simpler assertions