- Constructors
- Combinators
- Input Resolvers
- Error Constructors and Handlers
- Type-safe runtime utilities
- Utility Types
- Combinators with Context
- Serialization
It turns a function or a composition of functions into a ComposableWithSchema
which will have unknown
input and context, so the types will be asserted at runtime.
It is useful when dealing with external data, such as API requests, where you want to ensure the data is in the correct shape before processing it.
const fn = (
{ greeting }: { greeting: string },
{ user }: { user: { name: string } },
) => ({
message: `${greeting} ${user.name}`
})
const safeFunction = applySchema(
z.object({ greeting: z.string() }),
z.object({
user: z.object({ name: z.string() })
}),
)(fn)
type Test = typeof safeFunction
// ^? ComposableWithSchema<{ message: string }>
For didactit purposes: ComposableWithSchema<T> === Composable<(i?: unknown, c?: unknown) => T>
This is the primitive function to create composable functions. It takes a function and returns a composable function.
Note that a composition of composables is also a composable.
const add = composable((a: number, b: number) => a + b)
// ^? Composable<(a: number, b: number) => number>
const toString = composable((a: unknown) => `${a}`)
// ^? Composable<(a: unknown) => string>
const fn = pipe(add, toString)
// ^? Composable<(a: number, b: number) => string>
All composables are asynchronous and need to be awaited and checked for success or failure.
const result = await fn(1, 2)
console.log(
result.success ? result.data : `Can't process`
)
A composable will also catch any thrown errors and return them in a list as a failure.
const fn = composable((a: number) => {
throw new Error('Something went wrong')
return a * 2
})
const result = await fn(2)
console.log(result.errors[0].message)
// will log: 'Something went wrong'
failure
is a helper function to create a Failure
- aka: a failed result.
const result = failure([new Error('Something went wrong')])
// ^? Failure
expect(result).toEqual({
success: false,
errors: [new Error('Something went wrong')]
})
It will unwrap a composable expecting it to succeed. If it fails, it will throw the errors.
It is useful when you want to call other composables from inside your current one and there's no combinator to express your desired logic.
// Using inside other composables
const fn = composable(async (id: string) => {
const valueB = await fromSuccess(anotherComposable)({ userId: id })
// do something else
return { valueA, valueB }
})
It is also used to test the happy path of a composable.
const fn = map(pipe(add, multiplyBy2), (result) => result * 3)
const number = await fromSuccess(fn)(1, 1)
expect(number).toBe(12)
success
is a helper function to create a Success
- aka: a successful result.
const result = success(42)
// ^? Success<number>
expect(result).toEqual({
success: true,
data: 42
errors: []
})
These combinators are useful for composing functions. They operate on either plain functions or composables. They all return a Composable
, thus allowing further application in more compositions.
all
creates a single composable out of multiple composables.
It will pass the same arguments to each provided function.
If all constituent functions are successful, The data
field (on the composite function's result) will be a tuple containing each function's output.
const a = ({ id }: { id: number }) => String(id)
const b = ({ id }: { id: number }) => id + 1
const c = ({ id }: { id: number }) => Boolean(id)
const result = await all(a, b, c)({ id: 1 })
// ^? Result<[string, number, boolean]>
For the example above, the result will be:
{
success: true,
data: ['1', 2, true],
errors: [],
}
If any of the constituent functions fail, the errors
field (on the composite function's result) will be an array of the concatenated errors from each failing function:
const a = applySchema(z.object({ id: z.number() }))(({ id }) => {
return String(id)
})
const b = () => {
throw new Error('Error')
}
const result = await all(a, b)({ id: '1' })
// ^? Result<[string, never]>
/*{
success: false,
errors: [
new InputError('Expected number, received null', ['id']),
new Error('Error'),
],
}*/
Use branch
to add conditional logic to your compositions.
It receives a composable and a predicate function that should return the next composable to be executed based on the previous function's output, like pipe
.
const getIdOrEmail = (data: { id?: number, email?: string }) => data.id ?? data.email
const findUserById = (id: number) => db.users.find({ id })
const findUserByEmail = (email: string) => db.users.find({ email })
const findUserByIdOrEmail = branch(
getIdOrEmail,
(data) => (typeof data === "number" ? findUserById : findUserByEmail),
)
const result = await findUserByIdOrEmail({ id: 1 })
// ^? Result<User>
For the example above, the result will be:
{
success: true,
data: { id: 1, email: '[email protected]' },
errors: [],
}
If you don't want to pipe when a certain condition is matched, you can return null
like so:
const a = () => 'a'
const b = () => 'b'
const fn = branch(a, (data) => data === 'a' ? null : b)
// ^? Composable<() => 'a' | 'b'>
If any function fails, execution halts and the error is returned.
The predicate function will return a Failure
in case it throws:
const findUserByIdOrEmail = branch(
getIdOrEmail,
(data) => {
throw new Error("Invalid input")
},
)
// ^? Composable<({ id?: number, email?: string }) => never>
For the example above, the result type will be Failure
:
{ success: false, errors: [new Error('Invalid input')] }
You can catch an error in a Composable
, using catchFailure
which is similar to map
but will run whenever the composable fails:
import { composable, catchFailure } from 'composable-functions'
const getUser = (id: string) => fetchUser(id)
// ^? Composable<(id: string) => User>
const getOptionalUser = catchFailure(getUser, (errors, id) => {
console.log(`Failed to fetch user with id ${id}`, errors)
return null
})
// ^? Composable<(id: string) => User | null>
collect
works like the all
function but receives its constituent functions inside a record with string keys that identify each one. The shape of this record will be preserved for the data
property in successful results.
The motivation for this is that an object with named fields is often preferable to long tuples, when composing many composables.
const a = () => '1'
const b = () => 2
const c = () => true
const results = await collect({ a, b, c })({})
// ^? Result<{ a: string, b: number, c: boolean }>
For the example above, the result will be:
{
success: true,
data: { a: '1', b: 2, c: true },
errors: [],
}
As with the all
function, in case any function fails their errors will be concatenated.
map
creates a single composable that will apply a transformation over the result.data
of a successful Composable
.
When the given composable fails, its error is returned wihout changes.
If successful, mapper will receive the output of the composable as input.
const add = (a: number, b: number) => a + b
const addAndMultiplyBy2 = map(add, sum => sum * 2)
This can be useful when composing functions. For example, you might need to align input/output types in a pipeline:
const fetchAsText = ({ userId }: { userId: number }) => {
return fetch(`https://reqres.in/api/users/${String(userId)}`)
.then((r) => r.json())
}
const fullName = applySchema(
z.object({ first_name: z.string(), last_name: z.string() }),
)(({ first_name, last_name }) => `${first_name} ${last_name}`)
const fetchFullName = pipe(
map(fetchAsText, ({ data }) => data),
fullName,
)
const result = fetchFullName({ userId: 2 })
// ^? Result<string>
For the example above, the result will be something like this:
{
success: true,
data: 'Janet Weaver',
errors: [],
}
map
will also receive the input parameters of the composable as arguments:
const add = (a: number, b: number) => a + b
const aggregateInputAndOutput = map(add, (result, a, b) => ({ result, a, b }))
// ^? Composable<(a: number, b: number) => { result: number, a: number, b: number }>
mapErrors
creates a single composable that will apply a transformation over the Failure
of a failed Composable
.
When the given composable succeeds, its result is returned without changes.
This could be useful when adding any layer of error handling. In the example below, we are counting the errors but disregarding the contents:
const increment = (n: number) => {
if (Number.isNaN(n)) {
throw new Error('Invalid input')
}
return n + 1
}
const summarizeErrors = (errors: Error[]) =>
[new Error('Number of errors: ' + errors.length)]
const incrementWithErrorSummary = mapErrors(increment, summarizeErrors)
const result = await incrementWithErrorSummary({ invalidInput: '1' })
For the example above, the result
will be:
{
success: false,
errors: [new Error('Number of errors: 1')],
}
It takes a Composable and a function that will map the input parameters to the expected input of the given Composable. Good to adequate the output of a composable into the input of the next composable in a composition. The function must return an array of parameters that will be passed to the Composable.
const getUser = ({ id }: { id: number }) => db.users.find({ id })
const getCurrentUser = mapParameters(
getUser,
(_input: unknown, user: { id: number }) => [{ id: user.id }]
)
// ^? Composable<(input: unknown, ctx: { id: number }) => User>
pipe
creates a single composable out of a chain of multiple composables.
It will pass the output of a function as the next function's input in left-to-right order.
The resulting data will be the output of the rightmost function.
const a = (aNumber: number) => String(aNumber)
const b = (aString: string) => aString == '1'
const c = (aBoolean: boolean) => !aBoolean
const d = pipe(a, b, c)
const result = await d(1)
// ^? Result<boolean>
For the example above, the result will be:
{
success: true,
data: false,
errors: [],
}
If one functions fails, execution halts and the error is returned.
sequence
works exactly like the pipe
function, except the shape of the result is different.
Instead of the data
field being the output of the last composable, it will be a tuple containing each intermediate output (similar to the all
function).
const a = (aNumber: number) => String(aNumber)
const b = (aString: string) => aString == '1'
const c = (aBoolean: boolean) => !aBoolean
const d = sequence(a, b, c)
const result = await d(1)
// ^? Result<[string, boolean, boolean]>
For the example above, the result will be:
{
success: true,
data: ['1', true, false],
errors: [],
}
If you'd rather have a sequential combinator that returns an object - similar to collect - instead of a tuple, you can use the map
function like so:
const a = (aNumber: number) => String(aNumber)
const b = (aString: string) => aString === '1'
const c = map(sequence(a, b), ([a, b]) => ({ aString: a, aBoolean: b }))
const result = await c(1)
// ^? Result<{ aString: string, aBoolean: boolean }>
Whenever you need to intercept inputs and a composable result without changing them, there is a function called trace
that can help you.
The most common use case is to log failures to the console or to an external service. Let's say you want to log failed composables, you could create a function such as this:
const traceToConsole = trace((result, ...args) => {
if(!result.success) {
console.trace("Composable Failure ", result, ...args)
}
})
The args
above will be the tuple of arguments passed to the composable.
Then, assuming you want to trace all failures in a otherFn
, you just need to wrap it with the tracetoConsole
function:
traceToConsole(otherFn)
It would also be simple to create a function that will send the errors to some error tracking service under certain conditions:
const trackErrors = trace(async (result, ...args) => {
if(!result.success && someOtherConditions(result)) {
await sendToExternalService({ result, args })
}
})
We export some functions to help you extract values out of your requests before sending them as user input.
These functions are better suited for composables with runtime validation, such as those built with applySchema
since they deal with external data and applySchema
will ensure type-safety in runtime.
For more details on how to structure your data, refer to this test file.
inputFromForm
will read a request's FormData
and extract its values into a structured object:
// Given the following form:
function Form() {
return (
<form method="post">
<input name="email" value="[email protected]" />
<input name="password" value="1234" />
<button type="submit">
Submit
</button>
</form>
)
}
async (request: Request) => {
const values = await inputFromForm(request)
// values = { email: '[email protected]', password: '1234' }
}
inputFromFormData
extracts values from a FormData
object into a structured object:
const formData = new FormData()
formData.append('email', '[email protected]')
formData.append('tasks[]', 'one')
formData.append('tasks[]', 'two')
const values = inputFromFormData(formData)
// values = { email: '[email protected]', tasks: ['one', 'two'] }
inputFromUrl
will read a request's query params and extract its values into a structured object:
// Given the following form:
function Form() {
return (
<form method="get">
<button name="page" value="2">
Change URL
</button>
</form>
)
}
async (request: Request) => {
const values = inputFromUrl(request)
// values = { page: '2' }
}
inputFromSearch
extracts values from a URLSearchParams
object into a structured object:
const qs = new URLSearchParams()
qs.append('colors[]', 'red')
qs.append('colors[]', 'green')
qs.append('colors[]', 'blue')
const values = inputFromSearch(qs)
// values = { colors: ['red', 'green', 'blue'] }
All of the functions above will allow structured data as follows:
// Given the following form:
function Form() {
return (
<form method="post">
<input name="numbers[]" value="1" />
<input name="numbers[]" value="2" />
<input name="person[0][email]" value="[email protected]" />
<input name="person[0][password]" value="1234" />
<button type="submit">
Submit
</button>
</form>
)
}
async (request: Request) => {
const values = await inputFromForm(request)
/*
values = {
numbers: ['1', '2'],
person: [{ email: '[email protected]', password: '1234' }]
}
*/
}
The Failure
results contain a list of errors that can be of any extended class of Error
.
However, to help with composables with schema, we provide some constructors that will help you create errors to differentiate between kinds of errors.
An ErrorList
is a special kind of error that carries a list of errors that can be used to represent multiple errors in a single result.
const fn = composable(() => {
throw new ErrorList([
new InputError('Custom input error', ['contact', 'id']),
new ContextError('Custom context error', ['currentUser', 'role']),
])
})
const result = await fn()
// {
// success: false,
// errors: [
// new InputError('Custom input error', ['contact', 'id']),
// new ContextError('Custom context error', ['currentUser', 'role']),
// ],
// }
An ContextError
is a special kind of error that represents an error in the context schema.
It has an optional second parameter that is an array of strings representing the path to the error in the context schema.
const fn = applySchema(
z.object({ id: z.number() }),
z.object({
user: z.object({ id: z.string() }),
})
)(() => {})
const result = await fn({ id: '1' }, { user: { id: 1 } })
/* {
success: false,
errors: [
new ContextError(
'Expected string, received number',
['user', 'id'],
),
],
} */
You can also use the ContextError
constructor to throw errors within the composable:
const fn = composable(() => {
throw new ContextError('Custom context error', ['currentUser', 'role'])
})
Similar to ContextError
, an InputError
is a special kind of error that represents an error in the input schema.
isContextError
is a helper function that will check if an error is an instance of ContextError
.
isContextError(new ContextError('yes')) // true
isContextError(new Error('nope')) // false
isInputError
is a helper function that will check if an error is an instance of InputError
.
isInputError(new InputError('yes')) // true
isInputError(new Error('nope')) // false
mergeObjects
merges an array of objects into one object, preserving type inference completely.
Object properties from the rightmost object will take precedence over the leftmost ones.
const a = { a: 1, b: 2 }
const b = { b: '3', c: '4' }
const result = mergeObjects([a, b])
// ^? { a: number, b: string, c: string }
The resulting object will be:
{ a: 1, b: '3', c: '4' }
A Composable
type represents a function that resturns a Promise<Result<T>>
:
const fn = composable((a: number, b: number) => a + b)
type Test = typeof fn
// ^? Composable<(a: number, b: number) => number>
type Test2 = ReturnType<typeof fn>
// ^? Promise<Result<number>>
A Failure
type represents a failed result, which contains a list of errors and no data:
const f: Failure = {
success: false,
errors: [new Error('Something went wrong')],
}
A Result<T>
type represents the result of a Composable
function, which can be either a Success<T>
or a Failure
:
const r: Result<number> = {
success: true,
data: 42,
errors: [],
}
const r2: Result<number> = {
success: false,
errors: [new Error('Something went wrong')],
}
A Success<T>
type represents a successful result, which contains the data and an empty list of errors:
const s: Success<number> = {
success: true,
data: 42,
errors: [],
}
UnpackData
infers the returned data of a successful composable function:
const fn = composable()(async () => 'hey')
type Data = UnpackData<typeof fn>
// ^? string
The context is a concept of an argument that is passed to every functions of a sequential composition. When it comes to parallel compositions, all arguments are already forwarded to every function.
However in sequential compositions, we need a set of special combinators that will forward the context - the second parameter - to every function in the composition.
Use the sequential combinators from the namespace withContext
to get this behavior.
For a deeper explanation check the context docs.
It is the same as branch
but it will forward the context to the next composable.
import { withContext } from 'composable-functions'
const getIdOrEmail = (data: { id?: number, email?: string }) => {
return data.id ?? data.email
}
const findUserById = (id: number, ctx: { user: User }) => {
if (!ctx.user.admin) {
throw new Error('Unauthorized')
}
return db.users.find({ id })
}
const findUserByEmail = (email: string, ctx: { user: User }) => {
if (!ctx.user.admin) {
throw new Error('Unauthorized')
}
return db.users.find
}
const findUserByIdOrEmail = withContext.branch(
getIdOrEmail,
(data) => (typeof data === "number" ? findUserById : findUserByEmail),
)
const result = await findUserByIdOrEmail({ id: 1 }, { user: { admin: true } })
Similar to pipe
but it will forward the context to the next composable.
import { withContext } from 'composable-functions'
const a = (aNumber: number, ctx: { user: User }) => String(aNumber)
const b = (aString: string, ctx: { user: User }) => aString == '1'
const c = (aBoolean: boolean, ctx: { user: User }) => aBoolean && ctx.user.admin
const d = withContext.pipe(a, b, c)
const result = await d(1, { user: { admin: true } })
Similar to sequence
but it will forward the context to the next composable.
import { withContext } from 'composable-functions'
const a = (aNumber: number, ctx: { user: User }) => String(aNumber)
const b = (aString: string, ctx: { user: User }) => aString === '1'
const c = (aBoolean: boolean, ctx: { user: User }) => aBoolean && ctx.user.admin
const d = withContext.sequence(a, b, c)
const result = await d(1, { user: { admin: true } })
In distributed systems where errors might be serialized across network boundaries, it is important to preserve information relevant to error handling.
When serializing a Result
to send over the wire, some of the Error[]
information is lost.
To solve that you may use the serialize
helper that will turn the error list into a serializable format:
const serializedResult = JSON.stringify(serialize({
success: false,
errors: [new InputError('Oops', ['name'])],
}))
// serializedResult is:
`"{ success: false, errors: [{ message: 'Oops', name: 'InputError', path: ['name'] }] }"`
The resulting type is SerializableResult
which means Success<T> | { success: false, errors: SerializableError[] }
.
Therefore, you can differentiate the error using names and paths.
serializeError
is a helper function that will convert a single Error
into a SerializableError
object. It is used internally by serialize
:
const serialized = JSON.stringify(
serializeError(new InputError('Oops', ['name']))
)
// serialized is:
`"{ message: 'Oops', name: 'InputError', path: ['name'] }"`