composable-functions
Advanced tools
Comparing version 0.0.0-experimental-20240412-1 to 0.0.0-experimental-20240417-1
{ | ||
"name": "composable-functions", | ||
"version": "0.0.0-experimental-20240412-1", | ||
"version": "0.0.0-experimental-20240417-1", | ||
"description": "Decouple your business logic from your controllers. With first-class type inference from end to end.", | ||
@@ -5,0 +5,0 @@ "author": "Seasoned", |
930
README.md
@@ -1,86 +0,32 @@ | ||
# Keep your business logic clean with Domain Functions | ||
# Composable Functions | ||
Domain Functions helps you decouple your business logic from your controllers, with first-class type inference from end to end. | ||
It does this by enforcing the parameters' types at runtime (through [Zod](https://github.com/colinhacks/zod#what-is-zod) schemas) and always wrapping results (even exceptions) into a `Promise<Result<Output>>` type. | ||
![](example.gif) | ||
## Table of contents | ||
- [Benefits](#benefits) | ||
- [Quickstart](#quickstart) | ||
- [Using Deno](#using-deno) | ||
- [Taking parameters that are not user input](#taking-parameters-that-are-not-user-input) | ||
- [Dealing with errors](#dealing-with-errors) | ||
- [Changing the ErrorResult with Custom Errors](#changing-the-errorresult-with-custom-errors) | ||
- [ResultError constructor](#resulterror-constructor) | ||
- [Other error constructors](#other-error-constructors) | ||
- [Using error messages in the UI](#using-error-messages-in-the-ui) | ||
- [errorMessagesFor](#errormessagesfor) | ||
- [Tracing](#tracing) | ||
- [Combining domain functions](#combining-domain-functions) | ||
- [all](#all) | ||
- [collect](#collect) | ||
- [merge](#merge) | ||
- [first](#first) | ||
- [pipe](#pipe) | ||
- [branch](#branch) | ||
- [sequence](#sequence) | ||
- [collectSequence](#collectsequence) | ||
- [map](#map) | ||
- [mapError](#maperror) | ||
- [Runtime utilities](#runtime-utilities) | ||
- [fromSuccess](#fromsuccess) | ||
- [mergeObjects](#mergeobjects) | ||
- [Improve type inference with Utility Types](#improve-type-inference-with-utility-types) | ||
- [UnpackData](#unpackdata) | ||
- [UnpackSuccess](#unpacksuccess) | ||
- [UnpackResult](#unpackresult) | ||
- [Extracting input values for domain functions](#extracting-input-values-for-domain-functions) | ||
- [inputFromForm](#inputfromform) | ||
- [inputFromFormData](#inputfromformdata) | ||
- [inputFromUrl](#inputfromurl) | ||
- [inputFromSearch](#inputfromsearch) | ||
- [Resources](#resources) | ||
- [FAQ](#faq) | ||
- [Acknowlegements](#acknowlegements) | ||
## Benefits | ||
- Provides end-to-end type safety, all the way from the Backend to the UI | ||
- Removes the "plumbing": Extracting and parsing structured data from your Requests | ||
- Keeps your domain functions decoupled from the framework, with the assurance that your values conform to your types | ||
- Facilitates easier testing and maintainence of business logic | ||
- Allows business logic to be expressed in the type system | ||
## Quickstart | ||
``` | ||
npm i domain-functions zod | ||
npm i composable-functions | ||
``` | ||
```tsx | ||
import { makeDomainFunction, inputFromForm } from 'domain-functions' | ||
import * as z from 'zod' | ||
import { composable, pipe } from 'domain-functions' | ||
const schema = z.object({ number: z.coerce.number() }) | ||
const increment = makeDomainFunction(schema)(({ number }) => number + 1) | ||
const faultyAdd = composable((a: number, b: number) => { | ||
if (a === 1) throw new Error('a is 1') | ||
return a + b | ||
}) | ||
const show = composable(String) | ||
const addAndShow = pipe(faultyAdd, show) | ||
const result = await increment({ number: 1 }) | ||
const result = await addAndShow(2, 2) | ||
/* | ||
result = { | ||
success: true, | ||
data: 2, | ||
data: "4", | ||
errors: [] | ||
inputErrors: [] | ||
environmentErrors: [] | ||
} | ||
*/ | ||
const failedResult = await increment({ number: 'foo' }) | ||
const failedResult = await addAndShow(1, 2) | ||
/* | ||
failedResult = { | ||
success: false, | ||
inputErrors: [{ path: ['number'], message: 'Expected number, received nan' }], | ||
environmentErrors: [] | ||
errors: [], | ||
errors: [<Error object>] | ||
} | ||
@@ -90,832 +36,140 @@ */ | ||
To understand how to build the schemas, refer to [Zod documentation](https://github.com/colinhacks/zod#defining-schemas). | ||
## Composing type-safe functions | ||
Let's say we ant to compose two functions: `add : (a: number, b:number) => number` and `toString : (a: number) => string`. We also want the composition to preserve the types, we can continue living in the happy world of type-safe coding, the result would be a function that adds and converts the result to string, something like `addAndReturnString : (a: number, b: number) => string`. | ||
## Using Deno | ||
Performing this operation manually is straightforward | ||
If you are using [Deno](https://deno.land/), just directly import the functions you need from [deno.land/x](https://deno.land/x): | ||
```ts | ||
import { makeDomainFunction } from "https://deno.land/x/domain_functions/mod.ts"; | ||
``` | ||
This documentation will use Node.JS imports by convention, just replace `domain-functions` with `https://deno.land/x/domain_functions/mod.ts` when using [Deno](https://deno.land/). | ||
## Taking parameters that are not user input | ||
Sometimes you want to ensure the safety of certain values that weren't explicitly sent by the user. We call them _environment_: | ||
```tsx | ||
// In some app/domain/*.server.ts file | ||
const sendEmail = mdf( | ||
z.object({ email: z.string().email() }), // user input schema | ||
z.object({ origin: z.string() }) // environment schema | ||
)( | ||
async ({ email }, { origin }) => { | ||
mailer.send({ | ||
email, | ||
message: `Link to reset password: ${origin}/reset-password` | ||
}) | ||
} | ||
) | ||
// In your controller: | ||
async ({ request }) => { | ||
const environment = (request: Request) => ({ | ||
origin: new URL(request.url).origin, | ||
}) | ||
await sendEmail( | ||
await inputFromForm(request), | ||
environment(request), | ||
) | ||
```typescript | ||
function addAndReturnString(a: number, b: number) : string { | ||
return toString(add(a, b)) | ||
} | ||
``` | ||
We usually use the environment for ensuring authenticated requests. | ||
In this case, assume you have a `currentUser` function that returns the authenticated user: | ||
It would be neat if typescript could the typing for us and provided a more generic mechanism to compose these functions. Something like what you find in libraries such as [lodash](https://lodash.com/docs/4.17.15#flow) | ||
```tsx | ||
const dangerousFunction = mdf( | ||
someInputSchema, | ||
z.object({ user: z.object({ id: z.string(), admin: z.literal(true) }) }) | ||
)(async (input, { user }) => { | ||
// do something that only the admin can do | ||
}) | ||
``` | ||
Using composables the code could be written as: | ||
## Dealing with errors | ||
The error result has the following structure: | ||
```ts | ||
type ErrorResult = { | ||
success: false | ||
errors: Error[] | ||
inputErrors: SchemaError[] | ||
environmentErrors: SchemaError[] | ||
} | ||
```typescript | ||
const addAndReturnString = pipe(add, toString) | ||
``` | ||
The `inputErrors` and `environmentErrors` fields will be the errors from parsing the corresponding Zod schemas, and the `errors` field will be for any exceptions thrown inside the domain function (in which case we keep a reference to the original exception): | ||
We can also extend the same reasoning to functions that return promises in a transparent way. Imagine we have `add : (a: number, b:number) => Promise<number>` and `toString : (a: number) => Promise<string>`, the composition above would work in the same fashion, returning a function `addAndReturnString(a: number, b: number) : Promise<string>` that will wait for each promise in the chain before applying the next function. | ||
```ts | ||
const alwaysFails = mdf(input, environment)(async () => { | ||
throw new Error('Some error') | ||
}) | ||
This library also defines several operations besides the `pipe` to compose functions in arbitrary ways, giving a powerful tool for the developer to reason about the data flow without worrying about mistakenly connecting the wrong parameters or forgetting to unwrap some promise or handle some error along the way. | ||
const failedResult = await alwaysFails(someInput) | ||
/* | ||
failedResult = { | ||
success: false, | ||
errors: [{ message: 'Some error', exception: instanceOfError }], | ||
inputErrors: [], | ||
environmentErrors: [], | ||
} | ||
*/ | ||
``` | ||
## Creating primitive composables | ||
### Changing the ErrorResult with Custom Errors | ||
A `Composable` is a function that returns a `Promise<Result<T>>` where `T` is any type you want to return. Values of the type `Result` will represent either a failure (which carries a list of errors) or a success, where the computation has returned a value within the type `T`. | ||
### ResultError constructor | ||
So we can define the `add` and the `toString` functions as a `Composable`: | ||
Whenever you want more control over the domain function's `ErrorResult`, you can throw a `ResultError` from the domain function's handler. You will then be able to add multiple error messages to the structure: | ||
```typescript | ||
import { composable } from 'composable-functions' | ||
```ts | ||
const alwaysFails = mdf(inputSchema)(async () => { | ||
throw new ResultError({ | ||
errors: [{ message: 'Some error' }], | ||
inputErrors: [{ path: ['number'], message: 'Expected number, received nan' }], | ||
environmentErrors: [], // you can optionally omit this as it is empty. | ||
}) | ||
}) | ||
``` | ||
const add = composable((a: number, b: number) => a + b) | ||
^? Composable<(a: number, b: number) => number> | ||
### Other error constructors | ||
You can also throw an `InputError` whenever you want a custom input error that cannot be generated by your schema. | ||
```ts | ||
const alwaysFails = mdf(input, environment)(async () => { | ||
throw new InputError('Email already taken', 'email') | ||
}) | ||
const failedResult = await alwaysFails(someInput) | ||
// ^? Result<never> | ||
/* | ||
failedResult = { | ||
success: false, | ||
errors: [], | ||
inputErrors: [{ message: 'Email already taken', path: ['email'] }], | ||
environmentErrors: [], | ||
} | ||
*/ | ||
const toString = composable((a: unknown) => `${a}`) | ||
``` | ||
You can also return a custom environment error by throwing an `EnvironmentError`. | ||
## Sequential composition | ||
Now we can compose them using pipe to create `addAndReturnString`: | ||
### Using error messages in the UI | ||
```typescript | ||
import { pipe } from 'composable-functions' | ||
To improve DX when dealing with errors, we export a couple of utilities. | ||
#### errorMessagesFor | ||
Given an array of `SchemaError` -- be it from `inputErrors` or `environmentErrors` -- and a name, `errorMessagesFor` returns an array of error messages with that name in their path. | ||
```tsx | ||
const result = { | ||
success: false, | ||
errors: [], | ||
inputErrors: [], | ||
environmentErrors: [{ message: 'Must not be empty', path: ['host'] }, { message: 'Must be a fully qualified domain', path: ['host'] }] | ||
} | ||
errorMessagesFor(result.inputErrors, 'email') // will be an empty array: [] | ||
errorMessagesFor(result.environmentErrors, 'host')[0] === 'Must not be empty' | ||
const addAndReturnString = pipe(add, toString) | ||
^? Composable<(a: number, b: number) => string> | ||
``` | ||
### Tracing | ||
Note that trying to compose pipe flipping the arguments will not type-check: | ||
Whenever you need to intercept inputs and a domain function result without changing them, there is a function called `trace` that can help you. | ||
```typescript | ||
import { pipe } from 'composable-functions' | ||
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 domain functions, you could create a function such as this: | ||
```ts | ||
const traceToConsole = trace((context) => { | ||
if(!context.result.success) { | ||
console.trace("Domain Function Failure ", context) | ||
} | ||
}) | ||
const addAndReturnString = pipe(toString, add) | ||
^? ["Fail to compose", string, "does not fit in", number] | ||
``` | ||
Then, assuming you want to trace all failures in a `someOtherDomainFunction`, you just need to pass that domain function to our `tracetoConsole` function: | ||
The error message comes in the form of an inferred type (the type checker error is a bit more cryptic). | ||
Since pipe will compose from left to right, the only `string` output from `toString` will not fit into the first argument of `add` which is a `number`. | ||
```ts | ||
traceToConsole(someOtherDomainFunction)() | ||
``` | ||
### Using non-composables (mapping) | ||
It would also be simple to create a function that will send the errors to some error tracking service under certain conditions: | ||
Sometimes we want to use a simple function in this sort of sequential composition. Imagine that `toString` is not a composable, and you just want to apply a plain old function to the result of `add` when it succeeds. | ||
The function `map` can be used for this, since we are mapping over the result of a `Composable`: | ||
```ts | ||
const trackErrors = trace(async ({ input, output, result }) => { | ||
if(!result.success && someOtherConditions(result)) { | ||
await sendToExternalService({ input, output, result }) | ||
} | ||
}) | ||
``` | ||
```typescript | ||
import { map } from 'composable-functions' | ||
## Combining domain functions | ||
These combinators are useful for composing domain functions. They all return another `DomainFunction`, thus allowing further application in more compositions. | ||
### all | ||
`all` creates a single domain function out of multiple domain functions. | ||
It will pass the same input and environment to each provided function. | ||
If __all constituent functions__ are successful, The `data` field (on the composite domain function's result) will be a tuple containing each function's output. | ||
```ts | ||
const a = mdf(z.object({ id: z.number() }))(({ id }) => String(id)) | ||
const b = mdf(z.object({ id: z.number() }))(({ id }) => id + 1) | ||
const c = mdf(z.object({ id: z.number() }))(({ id }) => Boolean(id)) | ||
const results = await all(a, b, c)({ id: 1 }) | ||
// ^? Result<[string, number, boolean]> | ||
const addAndReturnString = map(add, String) | ||
``` | ||
For the example above, the result will be: | ||
Note that if your mapper function has to be `async` you should wrap it in `composable` and use `pipe` instead. | ||
```ts | ||
{ | ||
success: true, | ||
data: ['1', 2, true], | ||
errors: [], | ||
inputErrors: [], | ||
environmentErrors: [], | ||
} | ||
``` | ||
## Parallel composition | ||
If any of the constituent functions fail, the `errors` field (on the composite domain function's result) will be an array of the concatenated errors from each failing function: | ||
There are also functions compositions where all its parameters are excuted in parallel, like `Promise.all` will execute several promises and wait for all of them. | ||
The `all` function is one way of composing in this fashion. Assuming we want to apply our `add` and multiply the two numbers returning a success only once both operations succeed: | ||
```ts | ||
const a = mdf(z.object({ id: z.number() }))(() => { | ||
throw new Error('Error A') | ||
}) | ||
const b = mdf(z.object({ id: z.number() }))(() => { | ||
throw new Error('Error B') | ||
}) | ||
```typescript | ||
import { composable, all } from 'composable-functions' | ||
const results = await all(a, b)({ id: 1 }) | ||
// ^? Result<[never, never]> | ||
/*{ | ||
success: false, | ||
errors: [ | ||
{ message: 'Error A', exception: instanceOfErrorA }, | ||
{ message: 'Error B', exception: instanceOfErrorB } | ||
], | ||
inputErrors: [], | ||
environmentErrors: [], | ||
}*/ | ||
const add = composable((a: number, b: number) => a + b) | ||
const mul = composable((a: number, b: number) => a * b) | ||
const addAndMul = all(add, mul) | ||
^? Composable<(args_0: number, args_1: number) => [number, number]> | ||
``` | ||
### collect | ||
The result of the composition comes in a tuple in the same order as the functions were passed to `all`. | ||
Note that the input functions will also have to type-check and all the functions have to work from the same input. | ||
`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. | ||
## Handling errors | ||
Since a `Composable` always return a type `Result<T>` that might be either a failure or a success, there are never exceptions to catch. Any exception inside a `Composable` will return as an object with the shape: `{ success: false, errors: Error[] }`. | ||
The motivation for this is that an object with named fields is often preferable to long tuples, when composing many domain functions. | ||
Two neat consequences is that we can handle errors using functions (no need for try/catch blocks) and handle multiple errors at once. | ||
```ts | ||
const a = mdf(z.object({}))(() => '1') | ||
const b = mdf(z.object({}))(() => 2) | ||
const c = mdf(z.object({}))(() => true) | ||
### Throwing | ||
const results = await collect({ a, b, c })({}) | ||
// ^? Result<{ a: string, b: number, c: boolean }> | ||
``` | ||
### Catching | ||
To catch an error you need a second `Composable` capable of receiving `{ errors: Error[] }`. This composable is called when the first function fails: | ||
For the example above, the result will be: | ||
```typescript | ||
import { composable, catchError } from 'composable-functions' | ||
```ts | ||
{ | ||
success: true, | ||
data: { a: '1', b: 2, c: true }, | ||
errors: [], | ||
inputErrors: [], | ||
environmentErrors: [], | ||
} | ||
``` | ||
As with the `all` function, in case any function fails their errors will be concatenated. | ||
### merge | ||
`merge` works exactly like the `all` function, except __the shape of the result__ is different. | ||
Instead of returning a tuple, it will return a merged object which is equivalent to: | ||
```ts | ||
map(all(a, b, c), mergeObjects) | ||
``` | ||
**NOTE :** Try to use [collect](collect) instead wherever possible since it is much safer. `merge` can create domain functions that will always fail in run-time or even overwrite data from successful constituent functions application. The `collect` function does not have these issues and serves a similar purpose. | ||
The resulting data of every domain function will be merged into one object. __This could potentially lead to values of the leftmost functions being overwritten by the rightmost ones__. | ||
```ts | ||
const a = mdf(z.object({}))(() => ({ | ||
resultA: 'string', | ||
resultB: 'string', | ||
resultC: 'string', | ||
})) | ||
const b = mdf(z.object({}))(() => ({ resultB: 2 })) | ||
const c = mdf(z.object({}))(async () => ({ resultC: true })) | ||
const results = await merge(a, b, c)({}) | ||
// ^? Result<{ resultA: string, resultB: number, resultC: boolean }> | ||
``` | ||
For the example above, the result will be: | ||
```ts | ||
{ | ||
success: true, | ||
data: { resultA: 'string', resultB: 2, resultC: true }, | ||
errors: [], | ||
inputErrors: [], | ||
environmentErrors: [], | ||
} | ||
``` | ||
### first | ||
`first` will create a composite domain function that will return the result of the first successful constituent domain function. It handles inputs and environments like the `all` function. | ||
__It is important to notice__ that all constituent domain functions will be executed in parallel, so be mindful of the side effects. | ||
```ts | ||
const a = mdf( | ||
z.object({ n: z.number(), operation: z.literal('increment') }), | ||
)(({ n }) => n + 1) | ||
const b = mdf( | ||
z.object({ n: z.number(), operation: z.literal('decrement') }), | ||
)(({ n }) => n - 1) | ||
const result = await first(a, b)({ n: 1, operation: 'increment' }) | ||
// ^? Result<number> | ||
``` | ||
For the example above, the result will be: | ||
```ts | ||
{ | ||
success: true, | ||
data: 2, | ||
errors: [], | ||
inputErrors: [], | ||
environmentErrors: [], | ||
} | ||
``` | ||
The composite domain function's result type will be a union of each constituent domain function's result type. | ||
```ts | ||
const a = mdf(z.object({ operation: z.literal('A') }))(() => ({ | ||
resultA: 'A', | ||
})) | ||
const b = mdf(z.object({ operation: z.literal('B') }))(() => ({ | ||
resultB: 'B', | ||
})) | ||
const result = await first(a, b)({ operation: 'A' }) | ||
// ^? Result<{ resultA: string } | { resultB: string }> | ||
if (!result.success) return console.log('No function was successful') | ||
if ('resultA' in result.data) return console.log('function A succeeded') | ||
return console.log('function B succeeded') | ||
``` | ||
If every constituent domain function fails, the `errors` field will contain the concatenated errors from each failing function's result: | ||
```ts | ||
const a = mdf(z.object({ id: z.number() }))(() => { | ||
throw new Error('Error A') | ||
const fetchBody = composable((url: string) => fetch(url).then((r) => r.text())) | ||
const emptyOnError = composable(({errors}: { errors: Error[] }) => { | ||
console.error("Something went wrong, returning empty string", errors) | ||
return "" | ||
}) | ||
const b = mdf(z.object({ id: z.number() }))(() => { | ||
throw new Error('Error B') | ||
}) | ||
const result = await first(a, b)({ id: 1 }) | ||
// ^? Result<never> | ||
/*{ | ||
success: false, | ||
errors: [ | ||
{ message: 'Error A', exception: instanceOfErrorA }, | ||
{ message: 'Error B', exception: instanceOfErrorB } | ||
], | ||
inputErrors: [], | ||
environmentErrors: [], | ||
}*/ | ||
const fetchThatNeverFails = catchError(fetchBody, emptyOnError) | ||
``` | ||
### pipe | ||
### Mapping | ||
Sometimes we just need to transform one error into something that would make more sense for the caller. Imagine you have our `fetchBody` defined above, but we want a custom error type for when the input URL is invalid. You can map over the failures using `mapError` and a function with the type `({ errors: Error[] }) => { errors: Error[] }`. | ||
`pipe` creates a single domain function out of a chain of multiple domain functions. | ||
It will pass the same environment to all given functions, and 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. | ||
```typescript | ||
import { mapError } from 'composable-functions' | ||
Note that there is no type-level assurance that a function's output will align with and be succesfully parsed by the next function in the pipeline. | ||
```ts | ||
const a = mdf(z.object({ aNumber: z.number() }))( | ||
({ aNumber }) => ({ | ||
aString: String(aNumber), | ||
}), | ||
class InvalidUrlError extends Error {} | ||
const fetchBodyWithCustomError = mapError(fetchBody, (errors) => | ||
errors.map((e) => e.message.includes('Invalid URL') ? new InvalidUrlError() : e) | ||
) | ||
const b = mdf(z.object({ aString: z.string() }))( | ||
({ aString }) => ({ | ||
aBoolean: aString == '1', | ||
}), | ||
) | ||
const c = mdf(z.object({ aBoolean: z.boolean() }))( | ||
async ({ aBoolean }) => !aBoolean, | ||
) | ||
const d = pipe(a, b, c) | ||
const result = await d({ aNumber: 1 }) | ||
// ^? Result<boolean> | ||
``` | ||
For the example above, the result will be: | ||
## Recipes | ||
```ts | ||
{ | ||
success: true, | ||
data: false, | ||
errors: [], | ||
inputErrors: [], | ||
environmentErrors: [], | ||
} | ||
``` | ||
- Migrating from domain-functions | ||
- [Handling external input](./DFs.md) | ||
- [Defining constants for multiple functions (environments)](./DFs.md) | ||
If one functions fails, execution halts and the error is returned. | ||
## Using Deno | ||
### sequence | ||
If you are using [Deno](https://deno.land/), just directly import the functions you need from [deno.land/x](https://deno.land/x): | ||
`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 domain function, it will be a tuple containing each intermediate output (similar to the `all` function). | ||
```ts | ||
const a = mdf(z.number())((aNumber) => String(aNumber)) | ||
const b = mdf(z.string())((aString) => aString === '1') | ||
const c = sequence(a, b) | ||
const result = await c(1) | ||
// ^? Result<[string, boolean]> | ||
import { makeDomainFunction } from "https://deno.land/x/domain_functions/mod.ts"; | ||
``` | ||
For the example above, the result will be: | ||
This documentation will use Node.JS imports by convention, just replace `domain-functions` with `https://deno.land/x/domain_functions/mod.ts` when using [Deno](https://deno.land/). | ||
```ts | ||
{ | ||
success: true, | ||
data: ['1', true], | ||
errors: [], | ||
inputErrors: [], | ||
environmentErrors: [], | ||
} | ||
``` | ||
If you'd rather have an object instead of a tuple (similar to the `merge` function), you can use the `map` and `mergeObjects` functions like so: | ||
```ts | ||
import { mergeObjects } from 'domain-functions' | ||
const a = mdf(z.number())((aNumber) => ({ | ||
aString: String(aNumber) | ||
})) | ||
const b = mdf(z.object({ aString: z.string() }))( | ||
({ aString }) => ({ aBoolean: aString === '1' }) | ||
) | ||
const c = map(sequence(a, b), mergeObjects) | ||
const result = await c(1) | ||
// ^? Result<{ aString: string, aBoolean: boolean }> | ||
``` | ||
### collectSequence | ||
`collectSequence` is very similar to the `collect` function, except __it runs in the sequence of the keys' order like a `pipe`__. | ||
It 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. | ||
This feature relies on JS's order of objects' keys (guaranteed since ECMAScript2015). | ||
**NOTE :** For number-like object keys (eg: { 2: dfA, 1: dfB }) JS will follow ascendent order. | ||
```ts | ||
const a = mdf(z.number())((aNumber) => String(aNumber)) | ||
const b = mdf(z.string())((aString) => aString === '1') | ||
const c = collectSequence({ a, b }) | ||
const result = await c(1) | ||
// ^? Result<{ a: string, b: boolean }> | ||
``` | ||
For the example above, the result will be: | ||
```ts | ||
{ | ||
success: true, | ||
data: { a: '1', b: true }, | ||
errors: [], | ||
inputErrors: [], | ||
environmentErrors: [], | ||
} | ||
``` | ||
### branch | ||
Use `branch` to add conditional logic to your domain functions' compositions. | ||
It receives a domain function and a predicate function that should return the next domain function to be executed based on the previous domain function's output, like `pipe`. | ||
```ts | ||
const getIdOrEmail = mdf(z.object({ id: z.number().optional, email: z.string().optional() }))((data) => { | ||
return data.id ?? data.email | ||
}) | ||
const findUserById = mdf(z.number())((id) => { | ||
return db.users.find({ id }) | ||
}) | ||
const findUserByEmail = mdf(z.string().email())((email) => { | ||
return db.users.find({ email }) | ||
}) | ||
const findUserByIdOrEmail = branch( | ||
getIdOrEmail, | ||
(output) => (typeof output === "number" ? findUserById : findUserByEmail), | ||
) | ||
const result = await findUserByIdOrEmail({ id: 1 }) | ||
// ^? Result<User> | ||
``` | ||
For the example above, the result will be: | ||
```ts | ||
{ | ||
success: true, | ||
data: { id: 1, email: 'john@doe.com' }, | ||
errors: [], | ||
inputErrors: [], | ||
environmentErrors: [], | ||
} | ||
``` | ||
If you don't want to pipe when a certain condition is matched, you can return `null` like so: | ||
```ts | ||
const a = mdf()(() => 'a') | ||
const b = mdf()(() => 'b') | ||
const df = branch(a, (output) => output === 'a' ? null : b) | ||
// ^? DomainFunction<'a' | 'b'> | ||
``` | ||
If any function fails, execution halts and the error is returned. | ||
The predicate function will return an `ErrorResult` type in case it throws: | ||
```ts | ||
const findUserByIdOrEmail = branch( | ||
getIdOrEmail, | ||
(output) => { | ||
throw new Error("Invalid input") | ||
}, | ||
) | ||
// ^? DomainFunction<never> | ||
``` | ||
For the example above, the result type will be `ErrorResult`: | ||
```ts | ||
{ | ||
success: false, | ||
errors: [{ message: 'Invalid input' }], | ||
inputErrors: [], | ||
environmentErrors: [], | ||
} | ||
``` | ||
### map | ||
`map` creates a single domain function that will apply a transformation over the `result.data` of a successful `DomainFunction`. | ||
When the given domain function fails, its error is returned wihout changes. | ||
If successful, the `data` field will contain the output of the first function argument, mapped using the second function argument. | ||
This can be useful when composing domain functions. For example, you might need to align input/output types in a pipeline: | ||
```ts | ||
const fetchAsText = mdf(z.object({ userId: z.number() }))( | ||
({ userId }) => | ||
fetch(`https://reqres.in/api/users/${String(userId)}`).then((r) => | ||
r.json(), | ||
), | ||
) | ||
const fullName = mdf( | ||
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: | ||
```ts | ||
{ | ||
success: true, | ||
data: 'Janet Weaver', | ||
errors: [], | ||
inputErrors: [], | ||
environmentErrors: [], | ||
} | ||
``` | ||
### mapError | ||
`mapError` creates a single domain function that will apply a transformation over the `ErrorResult` of a failed `DomainFunction`. | ||
When the given domain function 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: | ||
```ts | ||
const increment = mdf(z.object({ id: z.number() }))( | ||
({ id }) => id + 1, | ||
) | ||
const summarizeErrors = (result: ErrorData) => | ||
({ | ||
errors: [{ message: 'Number of errors: ' + result.errors.length }], | ||
inputErrors: [ | ||
{ message: 'Number of input errors: ' + result.inputErrors.length }, | ||
], | ||
environmentErrors: [ | ||
{ message: 'Number of environment errors: ' + result.environmentErrors.length }, | ||
], | ||
} as ErrorData) | ||
const incrementWithErrorSummary = mapError(increment, summarizeErrors) | ||
const result = await incrementWithErrorSummary({ invalidInput: '1' }) | ||
``` | ||
For the example above, the `result` will be: | ||
```ts | ||
{ | ||
success: false, | ||
errors: [{ message: 'Number of errors: 0' }], | ||
inputErrors: [{ message: 'Number of input errors: 1' }], | ||
environmentErrors: [{ message: 'Number of environment errors: 0' }], | ||
} | ||
``` | ||
## Runtime utilities | ||
### fromSuccess | ||
Whenever the composition utilities fall short, and you want to call other domain functions from inside your current one, you can use the `fromSuccess` function to create a domain function that is expected to always succeed. | ||
```ts | ||
const domainFunctionA = mdf( | ||
z.object({ id: z.string() }), | ||
)(async ({ id }) => { | ||
const valueB = await fromSuccess(domainFunctionB)({ userId: id }) | ||
// do something else | ||
return { valueA, valueB } | ||
}) | ||
``` | ||
Otherwise, if the domain function passed to `fromSuccess` happens to fail, the error will be bubbled up exactly as it was thrown. | ||
### mergeObjects | ||
`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. | ||
```ts | ||
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: | ||
```ts | ||
{ a: 1, b: '3', c: '4' } | ||
``` | ||
## Improve type inference with Utility Types | ||
### UnpackData | ||
`UnpackData` infers the returned data of a successful domain function: | ||
```ts | ||
const fn = mdf()(async () => '') | ||
type Data = UnpackData<typeof fn> | ||
// ^? string | ||
``` | ||
### UnpackSuccess | ||
`UnpackSuccess` infers the success result of a domain function: | ||
```ts | ||
const fn = mdf()(async () => '') | ||
type Success = UnpackSuccess<typeof fn> | ||
// ^? SuccessResult<string> | ||
// Which is the same as: { success: true, data: string, errors: [], inputErrors: [], environmentErrors: [] } | ||
``` | ||
### UnpackResult | ||
`UnpackResult` infers the result of a domain function: | ||
```ts | ||
const fn = mdf()(async () => '') | ||
type Result = UnpackResult<typeof fn> | ||
// ^? Result<string> | ||
// Which is the same as: { success: true, data: string, errors: [], inputErrors: [], environmentErrors: [], } | { success: false, errors: { message: string }[], inputErrors: SchemaError[], environmentErrors: SchemaError[] } | ||
// Or the same as: SuccessResult<string> | ErrorResult | ||
``` | ||
## Extracting input values for domain functions | ||
We export some functions to help you extract values out of your requests before sending them as user input. | ||
### inputFromForm | ||
`inputFromForm` will read a request's `FormData` and extract its values into a structured object: | ||
```tsx | ||
// Given the following form: | ||
function Form() { | ||
return ( | ||
<form method="post"> | ||
<input name="email" value="john@doe.com" /> | ||
<input name="password" value="1234" /> | ||
<button type="submit"> | ||
Submit | ||
</button> | ||
</form> | ||
) | ||
} | ||
async (request: Request) => { | ||
const values = await inputFromForm(request) | ||
// values = { email: 'john@doe.com', password: '1234' } | ||
} | ||
``` | ||
### inputFromFormData | ||
`inputFromFormData` extracts values from a `FormData` object into a structured object: | ||
```tsx | ||
const formData = new FormData() | ||
formData.append('email', 'john@doe.com') | ||
formData.append('tasks[]', 'one') | ||
formData.append('tasks[]', 'two') | ||
const values = inputFromFormData(formData) | ||
// values = { email: 'john@doe.com', tasks: ['one', 'two'] } | ||
``` | ||
### inputFromUrl | ||
`inputFromUrl` will read a request's query params and extract its values into a structured object: | ||
```tsx | ||
// 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 | ||
`inputFromSearch` extracts values from a `URLSearchParams` object into a structured object: | ||
```tsx | ||
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: | ||
```tsx | ||
// 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="john@doe.com" /> | ||
<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: 'john@doe.com', password: '1234' }] | ||
} | ||
*/ | ||
} | ||
``` | ||
To better understand how to structure your data, refer to [this test file](./src/input-resolvers.test.ts) | ||
## Resources | ||
- [The case for domain-functions](https://dev.to/diogob/the-case-for-domain-functions-f4e) | ||
- [How domain-functions improves the already awesome DX of Remix projects](https://dev.to/gugaguichard/how-remix-domains-improves-the-already-awesome-dx-of-remix-projects-56lm) | ||
## FAQ | ||
- I want to use domain-functions in a project that does not have Zod, how can I use other schema validation libraries? | ||
- Although we code against Zod during the library development, any schema validation can be used as long as you are able to create an adapter of the type [`ParserSchema<T>`](./src/types.ts#L183). | ||
- Why are the inputs and the environment not type-safe? | ||
- Short answer: Similar to how Zod's `.parse` operates, we won't presume you're providing the right data to the domain function. We will validate it only at runtime. The domain function's inner code won't execute if the input/environment is invalid, ensuring that the data you receive is valid. Once validated, we can also infer the output type. Read more about it in [@danielweinmann 's comment](https://github.com/seasonedcc/domain-functions/issues/80#issuecomment-1642453221). | ||
- How do I carry out conditional branching in a composition of domain functions? | ||
- Before 1.8.0: You would have to use either the [`first`](#first) operator or `if` statements within the function. The `first` operator was not ideal because it could execute all the functions in the composition (assuming the input and environment validate) until one of them returns a success. For the `if` approach, we'd recommend using [`fromSuccess`](#fromsuccess) to invoke the other domain functions, as it would propagate any errors that could occur within them. Read more about it [here](https://twitter.com/gugaguichard/status/1684280544387899393). | ||
- After 1.8.0: We introduced the [`branch`](#branch) operator, which enables you to conduct more complex conditional branching without breaking compositions. | ||
## Acknowlegements | ||
We are grateful for [Zod](https://github.com/colinhacks/zod), as it is a great library and it informed our design. | ||
It's worth mentioning two other projects that inspired domain-functions: | ||
- [Servant](https://github.com/haskell-servant/servant/) | ||
- [tRPC](https://trpc.io) |
@@ -0,1 +1,2 @@ | ||
import { Internal } from './internal/types.js'; | ||
type Failure = { | ||
@@ -24,6 +25,3 @@ success: false; | ||
...infer rest | ||
] ? MergeObjs<rest, Prettify<Omit<output, keyof first> & first>> : output; | ||
type Prettify<T> = { | ||
[K in keyof T]: T[K]; | ||
} & {}; | ||
] ? MergeObjs<rest, Internal.Prettify<Omit<output, keyof first> & first>> : output; | ||
/** | ||
@@ -73,13 +71,4 @@ * It is similar to Partial<T> but it requires at least one property to be defined. | ||
Composable<(firstParameter: infer FirstBParameter, ...b: infer PB) => any> | ||
] ? IsNever<Awaited<OA>> extends true ? ['Fail to compose, "never" does not fit in', FirstBParameter] : Awaited<OA> extends FirstBParameter ? EveryElementTakesUndefined<PB> extends true ? PipeArguments<restA, [...Arguments, Composable<(...a: PA) => OA>]> : EveryElementTakesUndefined<PB> : ['Fail to compose', Awaited<OA>, 'does not fit in', FirstBParameter] : [...Arguments, Composable<(...a: PA) => OA>] : never; | ||
type EveryElementTakesUndefined<T extends any[]> = T extends [ | ||
infer HEAD, | ||
...infer TAIL | ||
] ? undefined extends HEAD ? true & EveryElementTakesUndefined<TAIL> : ['Fail to compose', undefined, 'does not fit in', HEAD] : true; | ||
type SubtypesTuple<TA extends unknown[], TB extends unknown[], O extends unknown[]> = TA extends [infer headA, ...infer restA] ? TB extends [infer headB, ...infer restB] ? headA extends headB ? SubtypesTuple<restA, restB, [...O, headA]> : headB extends headA ? SubtypesTuple<restA, restB, [...O, headB]> : { | ||
'Incompatible arguments ': true; | ||
argument1: headA; | ||
argument2: headB; | ||
} : SubtypesTuple<restA, [], [...O, headA]> : TB extends [infer headBNoA, ...infer restBNoA] ? SubtypesTuple<[], restBNoA, [...O, headBNoA]> : O; | ||
type AllArguments<Fns extends any[], Arguments extends any[] = []> = Fns extends [Composable<(...a: infer PA) => infer OA>, ...infer restA] ? restA extends [Composable<(...b: infer PB) => infer OB>, ...infer restB] ? SubtypesTuple<PA, PB, []> extends [...infer MergedP] ? AllArguments<[ | ||
] ? IsNever<Awaited<OA>> extends true ? ['Fail to compose, "never" does not fit in', FirstBParameter] : Awaited<OA> extends FirstBParameter ? Internal.EveryElementTakes<PB, undefined> extends true ? PipeArguments<restA, [...Arguments, Composable<(...a: PA) => OA>]> : Internal.EveryElementTakes<PB, undefined> : ['Fail to compose', Awaited<OA>, 'does not fit in', FirstBParameter] : [...Arguments, Composable<(...a: PA) => OA>] : never; | ||
type AllArguments<Fns extends any[], Arguments extends any[] = []> = Fns extends [Composable<(...a: infer PA) => infer OA>, ...infer restA] ? restA extends [Composable<(...b: infer PB) => infer OB>, ...infer restB] ? Internal.SubtypesTuple<PA, PB, []> extends [...infer MergedP] ? AllArguments<[ | ||
Composable<(...args: MergedP) => OB>, | ||
@@ -91,10 +80,4 @@ ...restB | ||
]> : ['Fail to compose', PA, 'does not fit in', PB] : [...Arguments, Composable<(...a: PA) => OA>] : never; | ||
type UnionToTuple<T> = ((T extends any ? (t: T) => T : never) extends infer U ? (U extends any ? (u: U) => any : never) extends (v: infer V) => any ? V : never : never) extends (_: any) => infer W ? [...UnionToTuple<Exclude<T, W>>, W] : []; | ||
type Keys<R extends Record<string, any>> = UnionToTuple<keyof R>; | ||
type RecordValuesFromKeysTuple<R extends Record<string, Composable>, K extends unknown[], ValuesTuple extends Composable[] = []> = K extends [infer Head, ...infer rest] ? Head extends string ? rest extends string[] ? RecordValuesFromKeysTuple<R, rest, [...ValuesTuple, R[Head]]> : never : ValuesTuple : ValuesTuple; | ||
type Zip<K extends unknown[], V extends Composable[], O extends Record<string, Composable> = {}> = K extends [infer HeadK, ...infer restK] ? V extends [infer HeadV, ...infer restV] ? HeadK extends string ? restK extends string[] ? restV extends Composable[] ? Zip<restK, restV, O & { | ||
[key in HeadK]: HeadV; | ||
}> : V : never : never : O : O; | ||
type CollectArguments<T extends Record<string, Composable>> = {} extends Zip<Keys<T>, AllArguments<RecordValuesFromKeysTuple<T, Keys<T>>>> ? never : Prettify<Zip<Keys<T>, AllArguments<RecordValuesFromKeysTuple<T, Keys<T>>>>>; | ||
type RecordToTuple<T extends Record<string, Composable>> = RecordValuesFromKeysTuple<T, Keys<T>>; | ||
type CollectArguments<T extends Record<string, Composable>> = {} extends Internal.Zip<Internal.Keys<T>, AllArguments<Internal.RecordValuesFromKeysTuple<T, Internal.Keys<T>>>> ? never : Internal.Zip<Internal.Keys<T>, AllArguments<Internal.RecordValuesFromKeysTuple<T, Internal.Keys<T>>>>; | ||
type RecordToTuple<T extends Record<string, Composable>> = Internal.RecordValuesFromKeysTuple<T, Internal.Keys<T>>; | ||
type SerializableError<T extends Error = Error> = { | ||
@@ -110,2 +93,2 @@ exception: T; | ||
}; | ||
export type { AllArguments, AtLeastOne, CollectArguments, Composable, Failure, First, Fn, Last, MergeObjs, PipeArguments, PipeReturn, Prettify, RecordToTuple, Result, SerializableError, SerializedResult, Success, TupleToUnion, UnpackAll, UnpackData, }; | ||
export type { AllArguments, AtLeastOne, CollectArguments, Composable, Failure, First, Fn, Last, MergeObjs, PipeArguments, PipeReturn, RecordToTuple, Result, SerializableError, SerializedResult, Success, TupleToUnion, UnpackAll, UnpackData, }; |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
New author
Supply chain riskA new npm collaborator published a version of the package for the first time. New collaborators are usually benign additions to a project, but do indicate a change to the security surface area of a package.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
50
2145
104173
175
1