diff --git a/API.md b/API.md new file mode 100644 index 00000000..84e8bab0 --- /dev/null +++ b/API.md @@ -0,0 +1,878 @@ +# API Reference +- [Constructors](#constructors) + - [applySchema](#applyschema) + - [composable](#composable) + - [failure](#failure) + - [fromSuccess](#fromsuccess) + - [success](#success) + - [withSchema](#withschema) +- [Combinators](#combinators) + - [all](#all) + - [branch](#branch) + - [catchFailure](#catchfailure) + - [collect](#collect) + - [map](#map) + - [mapErrors](#maperrors) + - [mapParameters](#mapparameters) + - [pipe](#pipe) + - [sequence](#sequence) + - [trace](#trace) +- [Input Resolvers](#input-resolvers) + - [inputFromForm](#inputfromform) + - [inputFromFormData](#inputfromformdata) + - [inputFromUrl](#inputfromurl) + - [inputFromSearch](#inputfromsearch) +- [Error Constructors and Handlers](#error-constructors-and-handlers) + - [ErrorList](#errorlist) + - [EnvironmentError](#environmenterror) + - [InputError](#inputerror) + - [isEnvironmentError](#isenvironmenterror) + - [isInputError](#isinputerror) +- [Type-safe runtime utilities](#type-safe-runtime-utilities) + - [mergeObjects](#mergeobjects) +- [Utility Types](#utility-types) + - [Composable](#composable-1) + - [Failure](#failure-1) + - [Result](#result) + - [Success](#success-1) + - [UnpackData](#unpackdata) +- [Combinators with Environment](#combinators-with-environment) + - [environment.branch](#environmentbranch) + - [environment.pipe](#environmentpipe) + - [environment.sequence](#environmentsequence) +- [Serialization](#serialization) + - [serialize](#serialize) + - [serializeError](#serializeerror) + + +# Constructors + +## applySchema +It takes a composable and schemas for the input and environment, and returns the same composable with the schemas applied. 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. + +```ts +const fn = composable(( + { 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 +// ^? Composable<(input?: unknown, env?: unknown) => { message: string }> +``` + +## composable +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. + +```ts +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. +```ts +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. +```ts +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 +`failure` is a helper function to create a `Failure` - aka: a failed result. + +```ts +const result = failure([new Error('Something went wrong')]) +// ^? Failure +expect(result).toEqual({ + success: false, + errors: [new Error('Something went wrong')] +}) +``` + +## fromSuccess +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. +```ts +// 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. + +```ts +const fn = map(pipe(add, multiplyBy2), (result) => result * 3) +const number = await fromSuccess(fn)(1, 1) +expect(number).toBe(12) +``` + +## success +`success` is a helper function to create a `Success` - aka: a successful result. + +```ts +const result = success(42) +// ^? Success +expect(result).toEqual({ + success: true, + data: 42 + errors: [] +}) +``` + +## withSchema +It creates a composable with unknown input and environment types, and applies the schemas to them so the arguments are assured at runtime. + +See `applySchema` above for more information. + +```ts +import { z } from 'zod' + +const runtimeSafeAdd = withSchema(z.number(), z.number())((a, b) => a + b) +// ^? Composable<(i?: unknown, e?: unknown) => number> +const result = await runtimeSafeAdd(1, 2) +console.log(result.success ? result.data : result.errors) +``` + +If there are input or environment errors, they will be returned in the `errors` property of the result. +```ts +const result = await runtimeSafeAdd('1', null) +// { +// success: false, +// errors: [ +// new InputError('Expected number, received string'), +// new EnvironmentError('Expected number, received null') +// ], +// } +``` + +# Combinators + +These combinators are useful for composing composables. They all return another `Composable`, thus allowing further application in more compositions. + +## all + +`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. + +```ts +const a = composable(({ id }: { id: number }) => String(id)) +const b = composable(({ id }: { id: number }) => id + 1) +const c = composable(({ 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: + +```ts +{ + 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: + +```ts +const a = withSchema(z.object({ id: z.number() }))(({ id }) => { + return String(id) +}) +const b = composable(() => { + 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'), + ], +}*/ +``` + +## branch +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`. + +```ts +const getIdOrEmail = composable((data: { id?: number, email?: string }) => { + return data.id ?? data.email +}) +const findUserById = composable((id: number) => { + return db.users.find({ id }) +}) +const findUserByEmail = composable((email: string) => { + return db.users.find({ email }) +}) +const findUserByIdOrEmail = branch( + getIdOrEmail, + (data) => (typeof data === "number" ? findUserById : findUserByEmail), +) +const result = await findUserByIdOrEmail({ id: 1 }) +// ^? Result +``` +For the example above, the result will be: +```ts +{ + success: true, + data: { id: 1, email: 'john@doe.com' }, + errors: [], +} +``` +If you don't want to pipe when a certain condition is matched, you can return `null` like so: +```ts +const a = composable(() => 'a') +const b = composable(() => '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: +```ts +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`: +```ts +{ success: false, errors: [new Error('Invalid input')] } +``` + +## catchFailure +You can catch an error in a `Composable`, using `catchFailure` which is similar to `map` but will run whenever the composable fails: + +```typescript +import { composable, catchFailure } from 'composable-functions' + +const getUser = composable((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 + +`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. + +```ts +const a = composable(() => '1') +const b = composable(() => 2) +const c = composable(() => true) + +const results = await collect({ a, b, c })({}) +// ^? Result<{ a: string, b: number, c: boolean }> +``` + +For the example above, the result will be: + +```ts +{ + 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 + +`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. + +```ts +const add = composable((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: + +```ts +const fetchAsText = composable( + ({ userId }: { userId: number }) => + fetch(`https://reqres.in/api/users/${String(userId)}`).then((r) => + r.json(), + ), +) +const fullName = withSchema( + 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 +``` + +For the example above, the result will be something like this: + +```ts +{ + success: true, + data: 'Janet Weaver', + errors: [], +} +``` + +`map` will also receive the input parameters of the composable as arguments: + +```ts +const add = composable((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 + +`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: + +```ts +const increment = composable((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: + +```ts +{ + success: false, + errors: [new Error('Number of errors: 1')], +} +``` + +## mapParameters +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. + +```ts +const getUser = composable(({ id }: { id: number }) => db.users.find({ id })) +// ^? Composable<(input: { id: number }) => User> + +const getCurrentUser = mapParameters( + getUser, + (_input, user: { id: number }) => [{ id: user.id }] +) +// ^? Composable<(input: unknown, env: { id: number }) => User> +``` + +## pipe + +`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. + +```ts +const a = composable((aNumber: number) => String(aNumber)) +const b = composable((aString: string) => aString == '1') +const c = composable((aBoolean: boolean) => !aBoolean) + +const d = pipe(a, b, c) + +const result = await d(1) +// ^? Result +``` + +For the example above, the result will be: + +```ts +{ + success: true, + data: false, + errors: [], +} +``` + +If one functions fails, execution halts and the error is returned. + +## sequence + +`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). + +```ts +const a = composable((aNumber: number) => String(aNumber)) +const b = composable((aString: string) => aString == '1') +const c = composable((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: + +```ts +{ + 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: + +```ts +const a = composable((aNumber: number) => String(aNumber)) +const b = composable((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 }> +``` +## trace +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: + +```ts +const traceToConsole = trace((result, ...args) => { + if(!context.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: + +```ts +traceToConsole(otherFn) +``` + +It would also be simple to create a function that will send the errors to some error tracking service under certain conditions: + +```ts +const trackErrors = trace(async (result, ...args) => { + if(!result.success && someOtherConditions(result)) { + await sendToExternalService({ result, args }) + } +}) +``` + +# Input Resolvers +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 use with `withSchema` rather than `composable` since they deal with external data and `withSchema` will ensure type-safety in runtime. + +For more details on how to structure your data, refer to this [test file](./src/tests/input-resolvers.test.ts). + +## inputFromForm + +`inputFromForm` will read a request's `FormData` and extract its values into a structured object: + +```tsx +// Given the following form: +function Form() { + return ( +
+ + + +
+ ) +} + +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 ( +
+ +
+ ) +} + +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 ( +
+ + + + + +
+ ) +} + +async (request: Request) => { + const values = await inputFromForm(request) + /* + values = { + numbers: ['1', '2'], + person: [{ email: 'john@doe.com', password: '1234' }] + } + */ +} +``` + +# Error Constructors and Handlers +The `Failure` results contain a list of errors that can be of any extended class of `Error`. +To help with composables `withSchema` though, we provide some constructors that will help you create errors to differentiate between kinds of errors. + +## ErrorList +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. + +```ts +const fn = composable(() => { + throw new ErrorList([ + new InputError('Custom input error', ['contact', 'id']), + new EnvironmentError('Custom env error', ['currentUser', 'role']), + ]) +}) +const result = await fn() +// { +// success: false, +// errors: [ +// new InputError('Custom input error', ['contact', 'id']), +// new EnvironmentError('Custom env error', ['currentUser', 'role']), +// ], +// } +``` + +## EnvironmentError +An `EnvironmentError` is a special kind of error that represents an error in the environment schema. + +It has an optional second parameter that is an array of strings representing the path to the error in the environment schema. + +```ts +const fn = withSchema( + 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 EnvironmentError( + 'Expected string, received number', + ['user', 'id'], + ), + ], +} */ +``` + +You can also use the `EnvironmentError` constructor to throw errors within the composable: + +```ts +const fn = composable(() => { + throw new EnvironmentError('Custom env error', ['currentUser', 'role']) +}) +``` + +## InputError +Similar to `EnvironmentError`, an `InputError` is a special kind of error that represents an error in the input schema. + +## isEnvironmentError +`isEnvironmentError` is a helper function that will check if an error is an instance of `EnvironmentError`. + +```ts +isEnvironmentError(new EnvironmentError('yes')) // true +isEnvironmentError(new Error('nope')) // false +``` + +## isInputError +`isInputError` is a helper function that will check if an error is an instance of `InputError`. + +```ts +isInputError(new InputError('yes')) // true +isInputError(new Error('nope')) // false +``` + +# Type-safe runtime utilities +## 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' } +``` + +# Utility Types + +## Composable +A `Composable` type represents a function that resturns a `Promise>`: + +```ts +const fn = composable((a: number, b: number) => a + b) +type Test = typeof fn +// ^? Composable<(a: number, b: number) => number> +type Test2 = ReturnType +// ^? Promise> +``` + +## Failure +A `Failure` type represents a failed result, which contains a list of errors and no data: + +```ts +const f: Failure = { + success: false, + errors: [new Error('Something went wrong')], +} +``` + +## Result +A `Result` type represents the result of a `Composable` function, which can be either a `Success` or a `Failure`: + +```ts +const r: Result = { + success: true, + data: 42, + errors: [], +} + +const r2: Result = { + success: false, + errors: [new Error('Something went wrong')], +} +``` +## Success +A `Success` type represents a successful result, which contains the data and an empty list of errors: + +```ts +const s: Success = { + success: true, + data: 42, + errors: [], +} +``` + +## UnpackData + +`UnpackData` infers the returned data of a successful composable function: + +```ts +const fn = composable()(async () => 'hey') + +type Data = UnpackData +// ^? string +``` + +# Combinators with Environment +The environment 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 environment - the second parameter - to every function in the composition. + +Use the sequential combinators from the namespace `environment` to get this behavior. + +For a deeper explanation check the [`environment` docs](./environments.md). + +## environment.branch +It is the same as `branch` but it will forward the environment to the next composable. + +```ts +import { environment } from 'composable-functions' + +const getIdOrEmail = composable((data: { id?: number, email?: string }) => { + return data.id ?? data.email +}) +const findUserById = composable((id: number, env: { user: User }) => { + if (!env.user.admin) { + throw new Error('Unauthorized') + } + return db.users.find({ id }) +}) +const findUserByEmail = composable((email: string, env: { user: User }) => { + if (!env.user.admin) { + throw new Error('Unauthorized') + } + return db.users.find +}) +const findUserByIdOrEmail = environment.branch( + getIdOrEmail, + (data) => (typeof data === "number" ? findUserById : findUserByEmail), +) +const result = await findUserByIdOrEmail({ id: 1 }, { user: { admin: true } }) +``` +## environment.pipe +Similar to `pipe` but it will forward the environment to the next composable. + +```ts +import { environment } from 'composable-functions' + +const a = composable((aNumber: number, env: { user: User }) => String(aNumber)) +const b = composable((aString: string, env: { user: User }) => aString == '1') +const c = composable((aBoolean: boolean, env: { user: User }) => aBoolean && env.user.admin) + +const d = environment.pipe(a, b, c) + +const result = await d(1, { user: { admin: true } }) +``` + +## environment.sequence +Similar to `sequence` but it will forward the environment to the next composable. + +```ts +import { environment } from 'composable-functions' + +const a = composable((aNumber: number, env: { user: User }) => String(aNumber)) +const b = composable((aString: string, env: { user: User }) => aString === '1') +const c = composable((aBoolean: boolean, env: { user: User }) => aBoolean && env.user.admin) + +const d = environment.sequence(a, b, c) + +const result = await d(1, { user: { admin: true } }) +``` + +# Serialization +In distributed systems where errors might be serialized across network boundaries, it is important to preserve information relevant to error handling. + +## serialize +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: + +```ts +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 | { success: false, errors: SerializableError[] }`. + +Therefore, you can differentiate the error using names and paths. + +## serializeError +`serializeError` is a helper function that will convert a single `Error` into a `SerializableError` object. It is used internally by `serialize`: + +```ts +const serialized = JSON.stringify( + serializeError(new InputError('Oops', ['name'])) +) + +// serialized is: +`"{ message: 'Oops', name: 'InputError', path: ['name'] }"` +``` diff --git a/README.md b/README.md index dc548a50..abda1c49 100644 --- a/README.md +++ b/README.md @@ -1,939 +1,277 @@ -# 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>` type. +A set of types and functions to make compositions easy and safe. -![](example.gif) +- 🛟 Type-Safe Compositions: Ensure robust type-safety during function composition, preventing incompatible functions from being combined and reducing runtime errors. +- 🔄 Promise and Error Handling: Focus on the happy-path of your functions eliminating the need for verbose try/catch syntax. +- 🏝️ Isolated Business Logic: Split your code into composable functions, making your code easier to test and maintain. +- 🔒 End-to-End Type Safety: Achieve end-to-end type safety from the backend to the UI with serializable results, ensuring data integrity across your entire application. +- ⚡ Parallel and Sequential Compositions: Compose functions both in parallel - with `all` and `collect` - and sequentially - with `pipe`, `branch`, and `sequence` -, to manage complex data flows optimizing your code for performance and clarity. +- 🕵️‍♂️ Runtime Validation: Use `withSchema` or `applySchema` with your favorite parser for optional runtime validation of inputs and environments, enforcing data integrity only when needed. +- 🚑 Resilient Error Handling: Leverage enhanced combinators like `mapErrors` and `catchFailure` to transform and handle errors more effectively. +- 📊 Traceable Compositions: Use the `trace` function to log and monitor your composable functions’ inputs and results, simplifying debugging and monitoring. -## Table of contents +#### Go to [API Reference](./API.md) -- [Benefits](#benefits) +## Table of contents - [Quickstart](#quickstart) +- [Composing type-safe functions](#composing-type-safe-functions) +- [Creating primitive composables](#creating-primitive-composables) +- [Sequential composition](#sequential-composition) + - [Using non-composables (mapping)](#using-non-composables-mapping) +- [Parallel composition](#parallel-composition) +- [Handling errors](#handling-errors) + - [Throwing](#throwing) + - [Catching](#catching) + - [Mapping the errors](#mapping-the-errors) +- [Unwrapping the result](#unwrapping-the-result) +- [Guides](#guides) + - [Migrating from domain-functions](#migrating-from-domain-functions) + - [Handling external input](#handling-external-input) + - [Defining constants for multiple functions (environments)](#defining-constants-for-multiple-functions-environments) + - [Using custom parsers](#using-custom-parsers) - [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 +- [Acknowledgements](#acknowledgements) ## 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 'composable-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: [] } */ ``` -To understand how to build the schemas, refer to [Zod documentation](https://github.com/colinhacks/zod#defining-schemas). - -## Using Deno - -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), - ) -} -``` - -We usually use the environment for ensuring authenticated requests. -In this case, assume you have a `currentUser` function that returns the authenticated user: - -```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 -}) -``` - -## Dealing with errors +## Composing type-safe functions +Let's say we want to compose two functions: `add: (a: number, b:number) => number` and `toString: (a: number) => string`. We also want the composition to preserve the types, so 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`. -The error result has the following structure: +Performing this operation manually is straightforward -```ts -type ErrorResult = { - success: false - errors: ErrorWithMessage[] - inputErrors: SchemaError[] - environmentErrors: SchemaError[] +```typescript +function addAndReturnString(a: number, b: number): string { + return toString(add(a, b)) } ``` -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): +It would be neat if typescript could do 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) -```ts -const alwaysFails = mdf(input, environment)(async () => { - throw new Error('Some error') -}) +Using composables the code could be written as: -const failedResult = await alwaysFails(someInput) -/* -failedResult = { - success: false, - errors: [{ message: 'Some error', exception: instanceOfError }], - inputErrors: [], - environmentErrors: [], -} -*/ +```typescript +const addAndReturnString = pipe(add, toString) ``` -### Changing the ErrorResult with Custom Errors +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` and `toString: (a: number) => Promise`, the composition above would work in the same fashion, returning a function `addAndReturnString(a: number, b: number): Promise` that will wait for each promise in the chain before applying the next function. -### ResultError constructor +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. -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: +## Creating primitive composables -```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. - }) -}) -``` +A `Composable` is a function that returns a `Promise>` 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`. -### Other error constructors +So we can define the `add` and the `toString` functions as a `Composable`: -You can also throw an `InputError` whenever you want a custom input error that cannot be generated by your schema. +```typescript +import { composable } from 'composable-functions' -```ts -const alwaysFails = mdf(input, environment)(async () => { - throw new InputError('Email already taken', 'email') -}) +const add = composable((a: number, b: number) => a + b) +// ^? Composable<(a: number, b: number) => number> -const failedResult = await alwaysFails(someInput) -// ^? Result -/* -failedResult = { - success: false, - errors: [], - inputErrors: [{ message: 'Email already taken', path: ['email'] }], - environmentErrors: [], -} -*/ +const toString = composable((a: unknown) => `${a}`) +// ^? Composable<(a: unknown) => string> ``` -To throw several input errors at once, you can use the pluralized version `InputErrors` like this: +## Sequential composition +Now we can compose them using pipe to create `addAndReturnString`: -```ts -const alwaysFails = mdf(input, environment)(async () => { - throw new InputErrors([{message: 'Email already taken', path: 'email'}, {message: 'Password too short', path: 'password'}]) -}) +```typescript +import { pipe } from 'composable-functions' -const failedResult = await alwaysFails(someInput) -// ^? Result -/* -failedResult = { - success: false, - errors: [], - inputErrors: [{ message: 'Email already taken', path: ['email'] }, { message: 'Password too short', path: ['password'] }], - environmentErrors: [], -} -*/ +const addAndReturnString = pipe(add, toString) +// ^? Composable<(a: number, b: number) => string> ``` -You can also return a custom environment error by throwing an `EnvironmentError`. - -### Using error messages in the UI - -To improve DX when dealing with errors, we export a couple of utilities. +Note that trying to compose pipe flipping the arguments will not type-check: -#### errorMessagesFor +```typescript +import { pipe } from 'composable-functions' -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(toString, add) +// ^? Internal.FailToCompose ``` -### Tracing - -Whenever you need to intercept inputs and a domain function 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 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) - } -}) -``` +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`. +The error message comes in the form of an inferred `FailToCompose` type, this failure type is not callable therefore it will break any attempts to call `addAndReturnString`. -Then, assuming you want to trace all failures in a `someOtherDomainFunction`, you just need to pass that domain function to our `tracetoConsole` function: +### Using non-composables (mapping) -```ts -traceToConsole(someOtherDomainFunction)() -``` +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`: -It would also be simple to create a function that will send the errors to some error tracking service under certain conditions: +```typescript +import { map } from 'composable-functions' -```ts -const trackErrors = trace(async ({ input, output, result }) => { - if(!result.success && someOtherConditions(result)) { - await sendToExternalService({ input, output, result }) - } -}) +const addAndReturnString = map(add, result => `${result}`) ``` -## Combining domain functions - -These combinators are useful for composing domain functions. They all return another `DomainFunction`, thus allowing further application in more compositions. +## Parallel composition -### all +There are also compositions where all functions 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: -`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. +```typescript +import { composable, all } from 'composable-functions' -```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]> -``` - -For the example above, the result will be: - -```ts -{ - success: true, - data: ['1', 2, true], - errors: [], - inputErrors: [], - environmentErrors: [], -} -``` - -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: - -```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') -}) - -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: [], -}*/ -``` - -### collect - -`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 domain functions. - -```ts -const a = mdf(z.object({}))(() => '1') -const b = mdf(z.object({}))(() => 2) -const c = mdf(z.object({}))(() => true) - -const results = await collect({ a, b, c })({}) -// ^? Result<{ a: string, b: number, c: boolean }> -``` - -For the example above, the result will be: - -```ts -{ - success: true, - data: { a: '1', b: 2, c: true }, - errors: [], - 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<(a: number, b: number) => [number, number]> ``` +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. -As with the `all` function, in case any function fails their errors will be concatenated. +## Handling errors +Since a `Composable` always return a type `Result` 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[] }`. -### merge +Two neat consequences is that we can handle errors using functions (no need for try/catch blocks) and handle multiple errors at once. -`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) -``` +### Throwing -**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. +You can throw anything derived from `Error`. Check [this documentation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error#custom_error_types) on how to define your custom errors. The library will also wrap anything that does not extends `Error`, just to keep compatibility with code-bases that throw strings or objects. -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__. +```typescript +import { composable } from 'composable-functions' -```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 -``` - -For the example above, the result will be: - -```ts -{ - success: true, - data: 2, - errors: [], - inputErrors: [], - environmentErrors: [], +class NotFoundError extends Error { + constructor(message) { + super(message); + this.name = 'NotFoundError'; + } } -``` - -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 b = mdf(z.object({ id: z.number() }))(() => { - throw new Error('Error B') +const getUser = composable((userId: string, users: Array) => { +// ^? Composable<(userId: string, users: Array) => string> + const result = users.find(({id}) => id === userId) + if(result == undefined) throw new NotFoundError(`userId ${userId} was not found`) + return result }) - -const result = await first(a, b)({ id: 1 }) -// ^? Result - -/*{ - success: false, - errors: [ - { message: 'Error A', exception: instanceOfErrorA }, - { message: 'Error B', exception: instanceOfErrorB } - ], - inputErrors: [], - environmentErrors: [], -}*/ -``` - -### pipe - -`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. - -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), - }), -) -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 ``` -For the example above, the result will be: +The library defines a few custom errors out of the box but these will be more important later on, whend dealing with external input and schemas. +See [the errors module](./src/errors.ts) for more details. -```ts -{ - success: true, - data: false, - errors: [], - inputErrors: [], - environmentErrors: [], -} -``` - -If one functions fails, execution halts and the error is returned. - -### sequence - -`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]> -``` - -For the example above, the result will be: - -```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 }> -``` +### Catching +You can catch an error in a `Composable`, using `catchFailure` which is similar to `map` but will run whenever the first composable fails: -For the example above, the result will be: +```typescript +import { composable, catchFailure } from 'composable-functions' -```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 +// assuming we have the definitions from the previous example +const getOptionalUser = catchFailure(getUser, (errors, id) => { + console.log(`Failed to fetch user with id ${id}`, errors) + return null }) -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 -``` -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'> +// ^? Composable<(id: string) => string | null> ``` -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 -``` -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. +### Mapping the errors +Sometimes we just need to transform the errors into something that would make more sense for the caller. Imagine you have our `getUser` defined above, but we want a custom error type for when the ID is invalid. You can map over the failures using `mapErrors` and a function with the type `(errors: Error[]) => Error[]`. -This can be useful when composing domain functions. For example, you might need to align input/output types in a pipeline: +```typescript +import { mapErrors } from 'composable-functions' -```ts -const fetchAsText = mdf(z.object({ userId: z.number() }))( - ({ userId }) => - fetch(`https://reqres.in/api/users/${String(userId)}`).then((r) => - r.json(), - ), +class InvalidUserId extends Error {} +const getUserWithCustomError = mapErrors(getUser, (errors) => + errors.map((e) => e.message.includes('Invalid ID') ? new InvalidUserId() : e) ) - -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 ``` +## Unwrapping the result +Keep in mind the `Result` type will only have a `data` property when the composable succeeds. If you want to unwrap the result, you must check for the `success` property first. -For the example above, the result will be something like this: +```typescript +const result = await getUser('123') +if (!result.success) return notFound() -```ts -{ - success: true, - data: 'Janet Weaver', - errors: [], - inputErrors: [], - environmentErrors: [], -} +return result.data +// ^? User ``` -### 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: - +TypeScript won't let you access the `data` property without checking for `success` first, so you can be sure that you are always handling the error case. ```ts -{ - success: false, - errors: [{ message: 'Number of errors: 0' }], - inputErrors: [{ message: 'Number of input errors: 1' }], - environmentErrors: [{ message: 'Number of environment errors: 0' }], -} +const result = await getUser('123') +// @ts-expect-error: Property 'data' does not exist on type 'Result' +return result.data ``` -## 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. +You can also use `fromSuccess` to unwrap the result of a composable that is expected to always succeed. Keep in mind that this function will throw an error if the composable fails so you're losing the safety layer of the `Result` type. ```ts -const domainFunctionA = mdf( - z.object({ id: z.string() }), -)(async ({ id }) => { - const valueB = await fromSuccess(domainFunctionB)({ userId: id }) +const fn = composable(async (id: string) => { + const valueB = await fromSuccess(anotherComposable)({ userId: id }) // do something else return { valueA, valueB } }) ``` +We recomend only using `fromSuccess` when you are sure the composable must succeed, like when you are testing the happy path of a composable. -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: +You can also use it within other composables whenever the composition utilities fall short, the error will be propagated as `ErrorList` and available in the caller `Result`. ```ts -const fn = mdf()(async () => '') +const getUser = composable((id: string) => db().collection('users').findOne({ id })) -type Data = UnpackData -// ^? string +const getProfile = composable(async (id: string) => { + const user = await fromSuccess(getUser)(id) + // ... some logic + return { user, otherData } +}) ``` -### UnpackSuccess +## Guides -`UnpackSuccess` infers the success result of a domain function: +#### [Migrating from domain-functions](./migrating-df.md) +#### [Handling external input](./with-schema.md) +#### [Defining constants for multiple functions (environments)](./environments.md) +#### [Using custom parsers](./examples/arktype/README.md) -```ts -const fn = mdf()(async () => '') - -type Success = UnpackSuccess -// ^? SuccessResult -// Which is the same as: { success: true, data: string, errors: [], inputErrors: [], environmentErrors: [] } -``` -### UnpackResult +## Using Deno -`UnpackResult` infers the result of a domain function: +If you are using [Deno](https://deno.land/), just directly import the functions you need from [deno.land/x](https://deno.land/x): ```ts -const fn = mdf()(async () => '') - -type Result = UnpackResult -// ^? Result -// 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 | ErrorResult +import { composable } from "https://deno.land/x/composable_functions/mod.ts"; ``` -## 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 ( -
- - - -
- ) -} - -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 ( -
- -
- ) -} - -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 ( -
- - - - - -
- ) -} - -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 +This documentation will use Node.JS imports by convention, just replace `composable-functions` with `https://deno.land/x/composable_functions/mod.ts` when using [Deno](https://deno.land/). -- 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`](./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 +## Acknowledgements -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: +Composable Functions' logo by [NUMI](https://github.com/numi-hq/open-design): -- [Servant](https://github.com/haskell-servant/servant/) -- [tRPC](https://trpc.io) +[NUMI Logo](https://numi.tech/?ref=string-ts) diff --git a/deno.json b/deno.json index d6210777..0827ce85 100644 --- a/deno.json +++ b/deno.json @@ -1,10 +1,11 @@ { - "version": "2.6.0", + "version": "1.0.0-beta-20240523-3", "tasks": { "test": "deno test --allow-env --allow-net src", "publish": "deno task build-npm && cd npm/ && npm publish", "build-npm": "deno run -A scripts/build-npm.ts", - "docs": "deno doc --html --name='domain-functions' ./mod.ts" + "docs": "deno doc --html --name='composable-functions' ./mod.ts", + "docs-lint": "deno doc --lint ./mod.ts" }, "lint": { "include": [ @@ -16,5 +17,17 @@ "ban-types" ] } + }, + "compilerOptions": { + "types": ["./src/test.d.ts"] + }, + "fmt": { + "useTabs": false, + "lineWidth": 80, + "indentWidth": 2, + "semiColons": false, + "singleQuote": true, + "proseWrap": "preserve", + "include": ["src/"] } } diff --git a/deno.lock b/deno.lock index 9b50b72a..2dc0da13 100644 --- a/deno.lock +++ b/deno.lock @@ -9,7 +9,6 @@ "https://deno.land/std@0.140.0/fmt/colors.ts": "30455035d6d728394781c10755351742dd731e3db6771b1843f9b9e490104d37", "https://deno.land/std@0.140.0/fs/_util.ts": "0fb24eb4bfebc2c194fb1afdb42b9c3dda12e368f43e8f2321f84fc77d42cb0f", "https://deno.land/std@0.140.0/fs/ensure_dir.ts": "9dc109c27df4098b9fc12d949612ae5c9c7169507660dcf9ad90631833209d9d", - "https://deno.land/std@0.140.0/hash/sha256.ts": "803846c7a5a8a5a97f31defeb37d72f519086c880837129934f5d6f72102a8e8", "https://deno.land/std@0.140.0/io/buffer.ts": "bd0c4bf53db4b4be916ca5963e454bddfd3fcd45039041ea161dbf826817822b", "https://deno.land/std@0.140.0/path/_constants.ts": "df1db3ffa6dd6d1252cc9617e5d72165cd2483df90e93833e13580687b6083c3", "https://deno.land/std@0.140.0/path/_interface.ts": "ee3b431a336b80cf445441109d089b70d87d5e248f4f90ff906820889ecf8d09", @@ -23,7 +22,9 @@ "https://deno.land/std@0.140.0/streams/conversion.ts": "712585bfa0172a97fb68dd46e784ae8ad59d11b88079d6a4ab098ff42e697d21", "https://deno.land/std@0.181.0/_util/asserts.ts": "178dfc49a464aee693a7e285567b3d0b555dc805ff490505a8aae34f9cfb1462", "https://deno.land/std@0.181.0/_util/os.ts": "d932f56d41e4f6a6093d56044e29ce637f8dcc43c5a90af43504a889cf1775e3", + "https://deno.land/std@0.181.0/fmt/colors.ts": "d67e3cd9f472535241a8e410d33423980bec45047e343577554d3356e1f0ef4e", "https://deno.land/std@0.181.0/fs/_util.ts": "65381f341af1ff7f40198cee15c20f59951ac26e51ddc651c5293e24f9ce6f32", + "https://deno.land/std@0.181.0/fs/empty_dir.ts": "c3d2da4c7352fab1cf144a1ecfef58090769e8af633678e0f3fabaef98594688", "https://deno.land/std@0.181.0/fs/ensure_dir.ts": "dc64c4c75c64721d4e3fb681f1382f803ff3d2868f08563ff923fdd20d071c40", "https://deno.land/std@0.181.0/fs/expand_glob.ts": "e4f56259a0a70fe23f05215b00de3ac5e6ba46646ab2a06ebbe9b010f81c972a", "https://deno.land/std@0.181.0/fs/walk.ts": "ea95ffa6500c1eda6b365be488c056edc7c883a1db41ef46ec3bf057b1c0fe32", @@ -36,22 +37,6 @@ "https://deno.land/std@0.181.0/path/posix.ts": "8b7c67ac338714b30c816079303d0285dd24af6b284f7ad63da5b27372a2c94d", "https://deno.land/std@0.181.0/path/separator.ts": "0fb679739d0d1d7bf45b68dacfb4ec7563597a902edbaf3c59b50d5bcadd93b1", "https://deno.land/std@0.181.0/path/win32.ts": "d186344e5583bcbf8b18af416d13d82b35a317116e6460a5a3953508c3de5bba", - "https://deno.land/std@0.182.0/_util/asserts.ts": "178dfc49a464aee693a7e285567b3d0b555dc805ff490505a8aae34f9cfb1462", - "https://deno.land/std@0.182.0/_util/os.ts": "d932f56d41e4f6a6093d56044e29ce637f8dcc43c5a90af43504a889cf1775e3", - "https://deno.land/std@0.182.0/fmt/colors.ts": "d67e3cd9f472535241a8e410d33423980bec45047e343577554d3356e1f0ef4e", - "https://deno.land/std@0.182.0/fs/_util.ts": "65381f341af1ff7f40198cee15c20f59951ac26e51ddc651c5293e24f9ce6f32", - "https://deno.land/std@0.182.0/fs/empty_dir.ts": "c3d2da4c7352fab1cf144a1ecfef58090769e8af633678e0f3fabaef98594688", - "https://deno.land/std@0.182.0/fs/expand_glob.ts": "e4f56259a0a70fe23f05215b00de3ac5e6ba46646ab2a06ebbe9b010f81c972a", - "https://deno.land/std@0.182.0/fs/walk.ts": "920be35a7376db6c0b5b1caf1486fb962925e38c9825f90367f8f26b5e5d0897", - "https://deno.land/std@0.182.0/path/_constants.ts": "e49961f6f4f48039c0dfed3c3f93e963ca3d92791c9d478ac5b43183413136e0", - "https://deno.land/std@0.182.0/path/_interface.ts": "6471159dfbbc357e03882c2266d21ef9afdb1e4aa771b0545e90db58a0ba314b", - "https://deno.land/std@0.182.0/path/_util.ts": "d7abb1e0dea065f427b89156e28cdeb32b045870acdf865833ba808a73b576d0", - "https://deno.land/std@0.182.0/path/common.ts": "ee7505ab01fd22de3963b64e46cff31f40de34f9f8de1fff6a1bd2fe79380000", - "https://deno.land/std@0.182.0/path/glob.ts": "d479e0a695621c94d3fd7fe7abd4f9499caf32a8de13f25073451c6ef420a4e1", - "https://deno.land/std@0.182.0/path/mod.ts": "bf718f19a4fdd545aee1b06409ca0805bd1b68ecf876605ce632e932fe54510c", - "https://deno.land/std@0.182.0/path/posix.ts": "8b7c67ac338714b30c816079303d0285dd24af6b284f7ad63da5b27372a2c94d", - "https://deno.land/std@0.182.0/path/separator.ts": "0fb679739d0d1d7bf45b68dacfb4ec7563597a902edbaf3c59b50d5bcadd93b1", - "https://deno.land/std@0.182.0/path/win32.ts": "d186344e5583bcbf8b18af416d13d82b35a317116e6460a5a3953508c3de5bba", "https://deno.land/std@0.206.0/assert/_constants.ts": "8a9da298c26750b28b326b297316cdde860bc237533b07e1337c021379e6b2a9", "https://deno.land/std@0.206.0/assert/_diff.ts": "58e1461cc61d8eb1eacbf2a010932bf6a05b79344b02ca38095f9b805795dc48", "https://deno.land/std@0.206.0/assert/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", @@ -88,40 +73,42 @@ "https://deno.land/std@0.206.0/testing/bdd.ts": "3f446df5ef8e856a869e8eec54c8482590415741ff0b6358a00c43486cc15769", "https://deno.land/x/code_block_writer@12.0.0/mod.ts": "2c3448060e47c9d08604c8f40dee34343f553f33edcdfebbf648442be33205e5", "https://deno.land/x/code_block_writer@12.0.0/utils/string_utils.ts": "60cb4ec8bd335bf241ef785ccec51e809d576ff8e8d29da43d2273b69ce2a6ff", - "https://deno.land/x/deno_cache@0.4.1/auth_tokens.ts": "5fee7e9155e78cedf3f6ff3efacffdb76ac1a76c86978658d9066d4fb0f7326e", - "https://deno.land/x/deno_cache@0.4.1/cache.ts": "51f72f4299411193d780faac8c09d4e8cbee951f541121ef75fcc0e94e64c195", - "https://deno.land/x/deno_cache@0.4.1/deno_dir.ts": "f2a9044ce8c7fe1109004cda6be96bf98b08f478ce77e7a07f866eff1bdd933f", - "https://deno.land/x/deno_cache@0.4.1/deps.ts": "8974097d6c17e65d9a82d39377ae8af7d94d74c25c0cbb5855d2920e063f2343", - "https://deno.land/x/deno_cache@0.4.1/dirs.ts": "d2fa473ef490a74f2dcb5abb4b9ab92a48d2b5b6320875df2dee64851fa64aa9", - "https://deno.land/x/deno_cache@0.4.1/disk_cache.ts": "1f3f5232cba4c56412d93bdb324c624e95d5dd179d0578d2121e3ccdf55539f9", - "https://deno.land/x/deno_cache@0.4.1/file_fetcher.ts": "07a6c5f8fd94bf50a116278cc6012b4921c70d2251d98ce1c9f3c352135c39f7", - "https://deno.land/x/deno_cache@0.4.1/http_cache.ts": "f632e0d6ec4a5d61ae3987737a72caf5fcdb93670d21032ddb78df41131360cd", - "https://deno.land/x/deno_cache@0.4.1/mod.ts": "ef1cda9235a93b89cb175fe648372fc0f785add2a43aa29126567a05e3e36195", - "https://deno.land/x/deno_cache@0.4.1/util.ts": "8cb686526f4be5205b92c819ca2ce82220aa0a8dd3613ef0913f6dc269dbbcfe", + "https://deno.land/x/deno_cache@0.6.2/auth_tokens.ts": "5d1d56474c54a9d152e44d43ea17c2e6a398dd1e9682c69811a313567c01ee1e", + "https://deno.land/x/deno_cache@0.6.2/cache.ts": "58b53c128b742757efcad10af9a3871f23b4e200674cb5b0ddf61164fb9b2fe7", + "https://deno.land/x/deno_cache@0.6.2/deno_dir.ts": "1ea355b8ba11c630d076b222b197cfc937dd81e5a4a260938997da99e8ff93a0", + "https://deno.land/x/deno_cache@0.6.2/deps.ts": "12cca94516cf2d3ed42fccd4b721ecd8060679253f077d83057511045b0081aa", + "https://deno.land/x/deno_cache@0.6.2/dirs.ts": "009c6f54e0b610914d6ce9f72f6f6ccfffd2d47a79a19061e0a9eb4253836069", + "https://deno.land/x/deno_cache@0.6.2/disk_cache.ts": "66a1e604a8d564b6dd0500326cac33d08b561d331036bf7272def80f2f7952aa", + "https://deno.land/x/deno_cache@0.6.2/file_fetcher.ts": "4f3e4a2c78a5ca1e4812099e5083f815a8525ab20d389b560b3517f6b1161dd6", + "https://deno.land/x/deno_cache@0.6.2/http_cache.ts": "407135eaf2802809ed373c230d57da7ef8dff923c4abf205410b9b99886491fd", + "https://deno.land/x/deno_cache@0.6.2/lib/deno_cache_dir.generated.js": "59f8defac32e8ebf2a30f7bc77e9d88f0e60098463fb1b75e00b9791a4bbd733", + "https://deno.land/x/deno_cache@0.6.2/lib/snippets/deno_cache_dir-a2aecaa9536c9402/fs.js": "cbe3a976ed63c72c7cb34ef845c27013033a3b11f9d8d3e2c4aa5dda2c0c7af6", + "https://deno.land/x/deno_cache@0.6.2/mod.ts": "b4004287e1c6123d7f07fe9b5b3e94ce6d990c4102949a89c527c68b19627867", + "https://deno.land/x/deno_cache@0.6.2/util.ts": "f3f5a0cfc60051f09162942fb0ee87a0e27b11a12aec4c22076e3006be4cc1e2", "https://deno.land/x/dir@1.5.1/data_local_dir/mod.ts": "91eb1c4bfadfbeda30171007bac6d85aadacd43224a5ed721bbe56bc64e9eb66", - "https://deno.land/x/dnt@0.38.0/lib/compiler.ts": "209ad2e1b294f93f87ec02ade9a0821f942d2e524104552d0aa8ff87021050a5", - "https://deno.land/x/dnt@0.38.0/lib/compiler_transforms.ts": "f21aba052f5dcf0b0595c734450842855c7f572e96165d3d34f8fed2fc1f7ba1", - "https://deno.land/x/dnt@0.38.0/lib/mod.deps.ts": "30367fc68bcd2acf3b7020cf5cdd26f817f7ac9ac35c4bfb6c4551475f91bc3e", - "https://deno.land/x/dnt@0.38.0/lib/npm_ignore.ts": "57fbb7e7b935417d225eec586c6aa240288905eb095847d3f6a88e290209df4e", - "https://deno.land/x/dnt@0.38.0/lib/package_json.ts": "61f35b06e374ed39ca776d29d67df4be7ee809d0bca29a8239687556c6d027c2", - "https://deno.land/x/dnt@0.38.0/lib/pkg/dnt_wasm.generated.js": "82aeecfb055af0b2700e1e9b886e4a44fe3bf9cd11a9c4195cb169f53a134b15", - "https://deno.land/x/dnt@0.38.0/lib/pkg/snippets/dnt-wasm-a15ef721fa5290c5/helpers.js": "a6b95adc943a68d513fe8ed9ec7d260ac466b7a4bced4e942f733e494bb9f1be", - "https://deno.land/x/dnt@0.38.0/lib/shims.ts": "df1bd4d9a196dca4b2d512b1564fff64ac6c945189a273d706391f87f210d7e6", - "https://deno.land/x/dnt@0.38.0/lib/test_runner/get_test_runner_code.ts": "4dc7a73a13b027341c0688df2b29a4ef102f287c126f134c33f69f0339b46968", - "https://deno.land/x/dnt@0.38.0/lib/test_runner/test_runner.ts": "4d0da0500ec427d5f390d9a8d42fb882fbeccc92c92d66b6f2e758606dbd40e6", - "https://deno.land/x/dnt@0.38.0/lib/transform.deps.ts": "e42f2bdef46d098453bdba19261a67cf90b583f5d868f7fe83113c1380d9b85c", - "https://deno.land/x/dnt@0.38.0/lib/types.ts": "b8e228b2fac44c2ae902fbb73b1689f6ab889915bd66486c8a85c0c24255f5fb", - "https://deno.land/x/dnt@0.38.0/lib/utils.ts": "878b7ac7003a10c16e6061aa49dbef9b42bd43174853ebffc9b67ea47eeb11d8", - "https://deno.land/x/dnt@0.38.0/mod.ts": "b13349fe77847cf58e26b40bcd58797a8cec5d71b31a1ca567071329c8489de1", - "https://deno.land/x/dnt@0.38.0/transform.ts": "f68743a14cf9bf53bfc9c81073871d69d447a7f9e3453e0447ca2fb78926bb1d", - "https://deno.land/x/ts_morph@18.0.0/bootstrap/mod.ts": "b53aad517f106c4079971fcd4a81ab79fadc40b50061a3ab2b741a09119d51e9", - "https://deno.land/x/ts_morph@18.0.0/bootstrap/ts_morph_bootstrap.js": "6645ac03c5e6687dfa8c78109dc5df0250b811ecb3aea2d97c504c35e8401c06", - "https://deno.land/x/ts_morph@18.0.0/common/DenoRuntime.ts": "6a7180f0c6e90dcf23ccffc86aa8271c20b1c4f34c570588d08a45880b7e172d", - "https://deno.land/x/ts_morph@18.0.0/common/mod.ts": "01985d2ee7da8d1caee318a9d07664774fbee4e31602bc2bb6bb62c3489555ed", - "https://deno.land/x/ts_morph@18.0.0/common/ts_morph_common.js": "845671ca951073400ce142f8acefa2d39ea9a51e29ca80928642f3f8cf2b7700", - "https://deno.land/x/ts_morph@18.0.0/common/typescript.js": "d5c598b6a2db2202d0428fca5fd79fc9a301a71880831a805d778797d2413c59", - "https://deno.land/x/wasmbuild@0.14.1/cache.ts": "89eea5f3ce6035a1164b3e655c95f21300498920575ade23161421f5b01967f4", - "https://deno.land/x/wasmbuild@0.14.1/loader.ts": "d98d195a715f823151cbc8baa3f32127337628379a02d9eb2a3c5902dbccfc02", + "https://deno.land/x/dnt@0.40.0/lib/compiler.ts": "7f4447531581896348b8a379ab94730856b42ae50d99043f2468328360293cb1", + "https://deno.land/x/dnt@0.40.0/lib/compiler_transforms.ts": "f21aba052f5dcf0b0595c734450842855c7f572e96165d3d34f8fed2fc1f7ba1", + "https://deno.land/x/dnt@0.40.0/lib/mod.deps.ts": "8d6123c8e1162037e58aa8126686a03d1e2cffb250a8757bf715f80242097597", + "https://deno.land/x/dnt@0.40.0/lib/npm_ignore.ts": "57fbb7e7b935417d225eec586c6aa240288905eb095847d3f6a88e290209df4e", + "https://deno.land/x/dnt@0.40.0/lib/package_json.ts": "607b0a4f44acad071a4c8533b312a27d6671eac8e6a23625c8350ce29eadb2ba", + "https://deno.land/x/dnt@0.40.0/lib/pkg/dnt_wasm.generated.js": "2694546844a50861d6d1610859afbf5130baca4dc6cf304541b7ec2d6d998142", + "https://deno.land/x/dnt@0.40.0/lib/pkg/snippets/dnt-wasm-a15ef721fa5290c5/helpers.js": "aba69a019a6da6f084898a6c7b903b8b583bc0dbd82bfb338449cf0b5bce58fd", + "https://deno.land/x/dnt@0.40.0/lib/shims.ts": "39e5c141f0315c0faf30b479b53f92b9078d92e1fd67ee34cc60b701d8e68dab", + "https://deno.land/x/dnt@0.40.0/lib/test_runner/get_test_runner_code.ts": "4dc7a73a13b027341c0688df2b29a4ef102f287c126f134c33f69f0339b46968", + "https://deno.land/x/dnt@0.40.0/lib/test_runner/test_runner.ts": "4d0da0500ec427d5f390d9a8d42fb882fbeccc92c92d66b6f2e758606dbd40e6", + "https://deno.land/x/dnt@0.40.0/lib/transform.deps.ts": "2e159661e1c5c650de9a573babe0e319349fe493105157307ec2ad2f6a52c94e", + "https://deno.land/x/dnt@0.40.0/lib/types.ts": "b8e228b2fac44c2ae902fbb73b1689f6ab889915bd66486c8a85c0c24255f5fb", + "https://deno.land/x/dnt@0.40.0/lib/utils.ts": "224f15f33e7226a2fd991e438d0291d7ed8c7889807efa2e1ecb67d2d1db6720", + "https://deno.land/x/dnt@0.40.0/mod.ts": "ae1890fbe592e4797e7dd88c1e270f22b8334878e9bf187c4e11ae75746fe778", + "https://deno.land/x/dnt@0.40.0/transform.ts": "f68743a14cf9bf53bfc9c81073871d69d447a7f9e3453e0447ca2fb78926bb1d", + "https://deno.land/x/ts_morph@20.0.0/bootstrap/mod.ts": "b53aad517f106c4079971fcd4a81ab79fadc40b50061a3ab2b741a09119d51e9", + "https://deno.land/x/ts_morph@20.0.0/bootstrap/ts_morph_bootstrap.js": "6645ac03c5e6687dfa8c78109dc5df0250b811ecb3aea2d97c504c35e8401c06", + "https://deno.land/x/ts_morph@20.0.0/common/DenoRuntime.ts": "6a7180f0c6e90dcf23ccffc86aa8271c20b1c4f34c570588d08a45880b7e172d", + "https://deno.land/x/ts_morph@20.0.0/common/mod.ts": "01985d2ee7da8d1caee318a9d07664774fbee4e31602bc2bb6bb62c3489555ed", + "https://deno.land/x/ts_morph@20.0.0/common/ts_morph_common.js": "2325f94f61dc5f3f98a1dab366dc93048d11b1433d718b10cfc6ee5a1cfebe8f", + "https://deno.land/x/ts_morph@20.0.0/common/typescript.js": "b9edf0a451685d13e0467a7ed4351d112b74bd1e256b915a2b941054e31c1736", + "https://deno.land/x/wasmbuild@0.15.1/cache.ts": "9d01b5cb24e7f2a942bbd8d14b093751fa690a6cde8e21709ddc97667e6669ed", + "https://deno.land/x/wasmbuild@0.15.1/loader.ts": "8c2fc10e21678e42f84c5135d8ab6ab7dc92424c3f05d2354896a29ccfd02a63", "https://deno.land/x/zod@v3.22.4/ZodError.ts": "4de18ff525e75a0315f2c12066b77b5c2ae18c7c15ef7df7e165d63536fdf2ea", "https://deno.land/x/zod@v3.22.4/errors.ts": "5285922d2be9700cc0c70c95e4858952b07ae193aa0224be3cbd5cd5567eabef", "https://deno.land/x/zod@v3.22.4/external.ts": "a6cfbd61e9e097d5f42f8a7ed6f92f93f51ff927d29c9fbaec04f03cbce130fe", diff --git a/environments.md b/environments.md new file mode 100644 index 00000000..4c8c7490 --- /dev/null +++ b/environments.md @@ -0,0 +1,92 @@ +## Environments + +Sometimes you want to ensure the safety of certain values that are constant accross sequential compositions. +This parameter is called an environment. And to simplify the composition of this kind of Composable +we always define it with a single input. Therefore we have the type + +```ts +Composable<(input: I, environment: E) => O> +``` + +A common use case would be a sequence of functions that depend on an authorization system. +The currently authenticated user would have to be propagated every time there is a sequential composition. +To avoid such awkwardness we use environments: + +```tsx +import { environment } from 'composable-functions' +const dangerousFunction = composable(async (input: string, { user } : { user: { name: string, admin: boolean } }) => { + // do something that only the admin can do +}) + +const carryUser = environment.pipe(gatherInput, dangerousFunction) +``` + +## Composing with environments + +These combinators are useful for composing functions with environment. Note that the standard parallel compositions will work just fine with the concept of environments. + +### `pipe` + +The environment.pipe function allows you to compose multiple functions in a sequence, forwarding the environment to each function in the chain. + +```ts +import { environment } from 'composable-functions' + +const a = composable((str: string, env: { user: User }) => str === '1') +const b = composable((bool: boolean, env: { user: User }) => bool && env.user.admin) + +const pipeline = environment.pipe(a, b) + +const result = await pipeline('1', { user: { admin: true } }) +/* +result = { + success: true, + data: true, + errors: [] +} +*/ +``` + +### `sequence` +The environment.sequence function works similarly to pipe, but it returns a tuple containing the result of each function in the sequence. + +```ts +import { environment } from 'composable-functions' + +const a = composable((str: string, env: { user: User }) => str === '1') +const b = composable((bool: boolean, env: { user: User }) => bool && env.user.admin) + +const sequence = environment.sequence(a, b) + +const result = await sequence('1', { user: { admin: true } }) +/* +result = { + success: true, + data: [true, true], + errors: [] +} +*/ +``` + +### `branch` + +The environment.branch function adds conditional logic to your compositions, forwarding the environment to each branch as needed. + +```ts +import { composable, environment } from 'composable-functions' + +const adminIncrement = composable((a: number, { user }: { user: { admin: boolean } }) => + user.admin ? a + 1 : a +) +const adminMakeItEven = (sum: number) => sum % 2 != 0 ? adminIncrement : null +const incrementUntilEven = environment.branch(adminIncrement, adminMakeItEven) + +const result = await incrementUntilEven(1, { user: { admin: true } }) +/* +result = { + success: true, + data: 2, + errors: [] +} +*/ +``` diff --git a/examples/arktype/.gitignore b/examples/arktype/.gitignore new file mode 100644 index 00000000..f1e61f37 --- /dev/null +++ b/examples/arktype/.gitignore @@ -0,0 +1,23 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/examples/arktype/README.md b/examples/arktype/README.md new file mode 100644 index 00000000..27bdb604 --- /dev/null +++ b/examples/arktype/README.md @@ -0,0 +1,9 @@ +# Use composable-functions with a custom parser + +This simple example can be a reference to adapt composable-functions to any other parser library. + +There are two approaches to use composable-functions with a custom parser: +- Create an adapter function that will receive a schema and return a schema in the shape of a `ParserSchena`. Example: [the `adapt` function](./src/adapters.ts). +- Create your custom `withSchema` and `applySchema` that will validate your input and environment and return a `Result`. Example: [the `withArkSchema` and `applyArkSchema` functions](./src/adapters.ts). + +Check out the [`./src`](./src/) directory to understand how we implemented both approaches with [`arktype`](https://github.com/arktypeio/arktype). diff --git a/examples/arktype/package.json b/examples/arktype/package.json new file mode 100644 index 00000000..9218ea6c --- /dev/null +++ b/examples/arktype/package.json @@ -0,0 +1,18 @@ +{ + "name": "composable-functions-arktype-example", + "private": true, + "version": "0.0.0", + "type": "module", + "main": "src/usage.ts", + "scripts": { + "dev": "tsx src/usage.ts" + }, + "devDependencies": { + "tsx": "^4.7.2" + }, + "dependencies": { + "arktype": "2.0.0-dev.7", + "composable-functions": "file:../../npm", + "typescript": "^5.4.5" + } +} diff --git a/examples/arktype/pnpm-lock.yaml b/examples/arktype/pnpm-lock.yaml new file mode 100644 index 00000000..0dc0a6d7 --- /dev/null +++ b/examples/arktype/pnpm-lock.yaml @@ -0,0 +1,329 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + arktype: + specifier: 2.0.0-dev.7 + version: 2.0.0-dev.7 + composable-functions: + specifier: file:../../npm + version: file:../../npm + typescript: + specifier: ^5.4.5 + version: 5.4.5 + devDependencies: + tsx: + specifier: ^4.7.2 + version: 4.7.2 + +packages: + + '@arktype/schema@0.0.7': + resolution: {integrity: sha512-awo14Oi98bn6pEgN7soiXvD+mQzidLgzABVO7Tpbw6xtU7+XRWp6JucSEmWLASC+fcoK0QSJxdoC+rm3iIKarQ==} + + '@arktype/util@0.0.33': + resolution: {integrity: sha512-MYbrLHf0tVYjxI84m0mMRISmKKVoPzv25B1/X05nePUcyPqROoDBn+hYhHpB0GqnJZQOr8UG1CyMuxjFeVbTNg==} + + '@esbuild/aix-ppc64@0.19.12': + resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.19.12': + resolution: {integrity: sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.19.12': + resolution: {integrity: sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.19.12': + resolution: {integrity: sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.19.12': + resolution: {integrity: sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.19.12': + resolution: {integrity: sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.19.12': + resolution: {integrity: sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.19.12': + resolution: {integrity: sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.19.12': + resolution: {integrity: sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.19.12': + resolution: {integrity: sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.19.12': + resolution: {integrity: sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.19.12': + resolution: {integrity: sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.19.12': + resolution: {integrity: sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.19.12': + resolution: {integrity: sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.19.12': + resolution: {integrity: sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.19.12': + resolution: {integrity: sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.19.12': + resolution: {integrity: sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.19.12': + resolution: {integrity: sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.19.12': + resolution: {integrity: sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.19.12': + resolution: {integrity: sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.19.12': + resolution: {integrity: sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.19.12': + resolution: {integrity: sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.19.12': + resolution: {integrity: sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + arktype@2.0.0-dev.7: + resolution: {integrity: sha512-TeehK+ExNsvqNoEccNOMs73LcNwR9+gX9pQsoCIvZfuxrQ24nB5MUQGweAAuNSwVX7GUUU9Ad0BWGnsvD8ST+g==} + + composable-functions@file:../../npm: + resolution: {directory: ../../npm, type: directory} + + esbuild@0.19.12: + resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==} + engines: {node: '>=12'} + hasBin: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@4.7.3: + resolution: {integrity: sha512-ZvkrzoUA0PQZM6fy6+/Hce561s+faD1rsNwhnO5FelNjyy7EMGJ3Rz1AQ8GYDWjhRs/7dBLOEJvhK8MiEJOAFg==} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + tsx@4.7.2: + resolution: {integrity: sha512-BCNd4kz6fz12fyrgCTEdZHGJ9fWTGeUzXmQysh0RVocDY3h4frk05ZNCXSy4kIenF7y/QnrdiVpTsyNRn6vlAw==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.4.5: + resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==} + engines: {node: '>=14.17'} + hasBin: true + +snapshots: + + '@arktype/schema@0.0.7': + dependencies: + '@arktype/util': 0.0.33 + + '@arktype/util@0.0.33': {} + + '@esbuild/aix-ppc64@0.19.12': + optional: true + + '@esbuild/android-arm64@0.19.12': + optional: true + + '@esbuild/android-arm@0.19.12': + optional: true + + '@esbuild/android-x64@0.19.12': + optional: true + + '@esbuild/darwin-arm64@0.19.12': + optional: true + + '@esbuild/darwin-x64@0.19.12': + optional: true + + '@esbuild/freebsd-arm64@0.19.12': + optional: true + + '@esbuild/freebsd-x64@0.19.12': + optional: true + + '@esbuild/linux-arm64@0.19.12': + optional: true + + '@esbuild/linux-arm@0.19.12': + optional: true + + '@esbuild/linux-ia32@0.19.12': + optional: true + + '@esbuild/linux-loong64@0.19.12': + optional: true + + '@esbuild/linux-mips64el@0.19.12': + optional: true + + '@esbuild/linux-ppc64@0.19.12': + optional: true + + '@esbuild/linux-riscv64@0.19.12': + optional: true + + '@esbuild/linux-s390x@0.19.12': + optional: true + + '@esbuild/linux-x64@0.19.12': + optional: true + + '@esbuild/netbsd-x64@0.19.12': + optional: true + + '@esbuild/openbsd-x64@0.19.12': + optional: true + + '@esbuild/sunos-x64@0.19.12': + optional: true + + '@esbuild/win32-arm64@0.19.12': + optional: true + + '@esbuild/win32-ia32@0.19.12': + optional: true + + '@esbuild/win32-x64@0.19.12': + optional: true + + arktype@2.0.0-dev.7: + dependencies: + '@arktype/schema': 0.0.7 + '@arktype/util': 0.0.33 + + composable-functions@file:../../npm: {} + + esbuild@0.19.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.19.12 + '@esbuild/android-arm': 0.19.12 + '@esbuild/android-arm64': 0.19.12 + '@esbuild/android-x64': 0.19.12 + '@esbuild/darwin-arm64': 0.19.12 + '@esbuild/darwin-x64': 0.19.12 + '@esbuild/freebsd-arm64': 0.19.12 + '@esbuild/freebsd-x64': 0.19.12 + '@esbuild/linux-arm': 0.19.12 + '@esbuild/linux-arm64': 0.19.12 + '@esbuild/linux-ia32': 0.19.12 + '@esbuild/linux-loong64': 0.19.12 + '@esbuild/linux-mips64el': 0.19.12 + '@esbuild/linux-ppc64': 0.19.12 + '@esbuild/linux-riscv64': 0.19.12 + '@esbuild/linux-s390x': 0.19.12 + '@esbuild/linux-x64': 0.19.12 + '@esbuild/netbsd-x64': 0.19.12 + '@esbuild/openbsd-x64': 0.19.12 + '@esbuild/sunos-x64': 0.19.12 + '@esbuild/win32-arm64': 0.19.12 + '@esbuild/win32-ia32': 0.19.12 + '@esbuild/win32-x64': 0.19.12 + + fsevents@2.3.3: + optional: true + + get-tsconfig@4.7.3: + dependencies: + resolve-pkg-maps: 1.0.0 + + resolve-pkg-maps@1.0.0: {} + + tsx@4.7.2: + dependencies: + esbuild: 0.19.12 + get-tsconfig: 4.7.3 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.4.5: {} diff --git a/examples/arktype/src/adapters.ts b/examples/arktype/src/adapters.ts new file mode 100644 index 00000000..6447eebe --- /dev/null +++ b/examples/arktype/src/adapters.ts @@ -0,0 +1,80 @@ +import { + composable, + Composable, + EnvironmentError, + failure, + InputError, + ParserSchema, + UnpackData, +} from 'composable-functions' +import { type, Type } from 'arktype' + +/** + * Approach 1: Adapt your schema to return a ParserSchema + */ +function adapt(schema: T) { + return { + safeParse: (val: unknown) => { + const result = schema(val) + if (result.errors) { + return { + success: false, + error: { + issues: result.errors.map((e) => ({ + path: e.path as string[], + message: e.message, + })), + }, + } + } + return { + success: true, + data: result.data as T['infer'], + } + }, + } as ParserSchema +} + +/** + * Approach 2: Create your custom `withSchema` and `applySchema` functions that will return a `Result` + */ +function withArkSchema( + inputSchema?: Type, + environmentSchema?: Type, +) { + return function ( + handler: (input: I, environment: E) => Output, + ): Composable<(input?: unknown, environment?: unknown) => Awaited> { + return applyArkSchema(inputSchema, environmentSchema)(composable(handler)) + } +} + +function applyArkSchema( + inputSchema?: Type, + environmentSchema?: Type, +) { + return (fn: A) => { + return async function (input: I, environment: E) { + const envResult = (environmentSchema ?? type('unknown'))(environment) + const result = (inputSchema ?? type('unknown'))(input) + + if (result.errors || envResult.errors) { + const inputErrors = Array.isArray(result.errors) + ? result.errors.map( + (error) => new InputError(error.message, error.path as string[]), + ) + : [] + const envErrors = Array.isArray(envResult.errors) + ? envResult.errors.map( + (error) => + new EnvironmentError(error.message, error.path as string[]), + ) + : [] + return failure([...inputErrors, ...envErrors]) + } + return fn(result.data as I, envResult.data as E) + } as Composable<(input?: unknown, environment?: unknown) => UnpackData> + } +} + +export { adapt, withArkSchema, applyArkSchema } diff --git a/examples/arktype/src/usage.ts b/examples/arktype/src/usage.ts new file mode 100644 index 00000000..c8fbb81f --- /dev/null +++ b/examples/arktype/src/usage.ts @@ -0,0 +1,30 @@ +import { composable, withSchema } from 'composable-functions' +import { applyArkSchema, withArkSchema, adapt } from './adapters' +import { type } from 'arktype' + +const appliedFn = applyArkSchema(type({ a: 'number', b: 'number' }))( + composable(({ a, b }: { a: number; b: number }) => a + b), +) +const withFn = withArkSchema( + type({ a: 'number' }), + type({ b: 'number' }), +)(({ a }, { b }) => a + b) + +const withAdapt = withSchema(adapt(type({ a: 'number', b: 'number' })))( + ({ a, b }) => a + b, +) + +const resultApplied = await appliedFn({ a: 1, b: 2 }) +console.log(resultApplied) +// { success: true, data: 3, errors: [] } + +const resultWith = await withFn({ a: '1' }, { b: 2 }) +console.log(resultWith) +// { +// success: false, +// errors: [InputError("must be a number (was string)", ["a"])] +// } + +const resultAdapted = await withAdapt({ a: 1, b: 2 }) +console.log(resultAdapted) +// { success: true, data: 3, errors: [] } diff --git a/examples/arktype/tsconfig.json b/examples/arktype/tsconfig.json new file mode 100644 index 00000000..75abdef2 --- /dev/null +++ b/examples/arktype/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/examples/remix/.eslintrc b/examples/remix/.eslintrc deleted file mode 100644 index 71569754..00000000 --- a/examples/remix/.eslintrc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": ["@remix-run/eslint-config", "@remix-run/eslint-config/node"] -} diff --git a/examples/remix/.eslintrc.cjs b/examples/remix/.eslintrc.cjs new file mode 100644 index 00000000..4f6f59ee --- /dev/null +++ b/examples/remix/.eslintrc.cjs @@ -0,0 +1,84 @@ +/** + * This is intended to be a basic starting point for linting in your app. + * It relies on recommended configs out of the box for simplicity, but you can + * and should modify this configuration to best suit your team's needs. + */ + +/** @type {import('eslint').Linter.Config} */ +module.exports = { + root: true, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + ecmaFeatures: { + jsx: true, + }, + }, + env: { + browser: true, + commonjs: true, + es6: true, + }, + ignorePatterns: ["!**/.server", "!**/.client"], + + // Base config + extends: ["eslint:recommended"], + + overrides: [ + // React + { + files: ["**/*.{js,jsx,ts,tsx}"], + plugins: ["react", "jsx-a11y"], + extends: [ + "plugin:react/recommended", + "plugin:react/jsx-runtime", + "plugin:react-hooks/recommended", + "plugin:jsx-a11y/recommended", + ], + settings: { + react: { + version: "detect", + }, + formComponents: ["Form"], + linkComponents: [ + { name: "Link", linkAttribute: "to" }, + { name: "NavLink", linkAttribute: "to" }, + ], + "import/resolver": { + typescript: {}, + }, + }, + }, + + // Typescript + { + files: ["**/*.{ts,tsx}"], + plugins: ["@typescript-eslint", "import"], + parser: "@typescript-eslint/parser", + settings: { + "import/internal-regex": "^~/", + "import/resolver": { + node: { + extensions: [".ts", ".tsx"], + }, + typescript: { + alwaysTryTypes: true, + }, + }, + }, + extends: [ + "plugin:@typescript-eslint/recommended", + "plugin:import/recommended", + "plugin:import/typescript", + ], + }, + + // Node + { + files: [".eslintrc.cjs"], + env: { + node: true, + }, + }, + ], +}; diff --git a/examples/remix/.gitignore b/examples/remix/.gitignore index 8ec1e75c..80ec311f 100644 --- a/examples/remix/.gitignore +++ b/examples/remix/.gitignore @@ -2,7 +2,4 @@ node_modules /.cache /build -/public/build .env -app/styles/tailwind.css -yarn.lock diff --git a/examples/remix/.prettierrc b/examples/remix/.prettierrc deleted file mode 100644 index d22544ca..00000000 --- a/examples/remix/.prettierrc +++ /dev/null @@ -1,14 +0,0 @@ -{ - "arrowParens": "always", - "bracketSameLine": false, - "bracketSpacing": true, - "jsxBracketSameLine": false, - "jsxSingleQuote": false, - "parser": "typescript", - "printWidth": 80, - "quoteProps": "as-needed", - "semi": false, - "singleQuote": true, - "tabWidth": 2, - "trailingComma": "all" -} diff --git a/examples/remix/README.md b/examples/remix/README.md new file mode 100644 index 00000000..c05e097d --- /dev/null +++ b/examples/remix/README.md @@ -0,0 +1,36 @@ +# Welcome to Remix + Vite! + +📖 See the [Remix docs](https://remix.run/docs) and the [Remix Vite docs](https://remix.run/docs/en/main/future/vite) for details on supported features. + +## Development + +Run the Vite dev server: + +```shellscript +npm run dev +``` + +## Deployment + +First, build your app for production: + +```sh +npm run build +``` + +Then run the app in production mode: + +```sh +npm start +``` + +Now you'll need to pick a host to deploy it to. + +### DIY + +If you're familiar with deploying Node applications, the built-in Remix app server is production-ready. + +Make sure to deploy the output of `npm run build` + +- `build/server` +- `build/client` diff --git a/examples/remix/app/domain/colors.ts b/examples/remix/app/business/colors.ts similarity index 71% rename from examples/remix/app/domain/colors.ts rename to examples/remix/app/business/colors.ts index 16f6b87d..54e84c2c 100644 --- a/examples/remix/app/domain/colors.ts +++ b/examples/remix/app/business/colors.ts @@ -1,5 +1,5 @@ import * as z from 'zod' -import { makeDomainFunction as mdf } from 'domain-functions' +import { composable, withSchema } from 'composable-functions' import { makeService } from 'make-service' const reqRes = makeService('https://reqres.in/api') @@ -12,19 +12,17 @@ const colorSchema = z.object({ pantone_value: z.string(), }) -const listColors = mdf(z.object({ page: z.string().optional() }))(async ({ - page = '1', -}) => { +const listColors = composable(async ({ page = '1' }: { page?: string }) => { const response = await reqRes.get('/colors', { query: { page } }) return response.json(z.object({ data: z.array(colorSchema) })) }) -const getColor = mdf(z.object({ id: z.string() }))(async ({ id }) => { +const getColor = composable(async ({ id }: { id: string }) => { const response = await reqRes.get('/colors/:id', { params: { id } }) return response.json(z.object({ data: colorSchema })) }) -const mutateColor = mdf( +const mutateColor = withSchema( z.object({ id: z.string(), color: z.string().min(1, 'Color is required'), @@ -34,7 +32,8 @@ const mutateColor = mdf( params: { id }, body: { color }, }) - return response.json(colorSchema.pick({ color: true, id: true })) + await response.json(colorSchema.pick({ id: true })) + return { color } }) export { listColors, getColor, mutateColor } diff --git a/examples/remix/app/domain/gpd.ts b/examples/remix/app/business/gpd.ts similarity index 71% rename from examples/remix/app/domain/gpd.ts rename to examples/remix/app/business/gpd.ts index 12ab4434..bb1304a4 100644 --- a/examples/remix/app/domain/gpd.ts +++ b/examples/remix/app/business/gpd.ts @@ -1,12 +1,12 @@ -import * as z from 'zod' -import { makeDomainFunction as mdf } from 'domain-functions' +import { z } from 'zod' +import { withSchema } from 'composable-functions' import { createCookie } from '@remix-run/node' const cookie = createCookie('gpd', { - maxAge: 60, // One minute, but should probably be longer + maxAge: 20, // seconds }) -const getGPDInfo = mdf( +const getGPDInfo = withSchema( z.any(), // The "environment" knows there can be cookie information in the Request z.object({ agreed: z.boolean().optional() }), @@ -14,7 +14,7 @@ const getGPDInfo = mdf( return { agreed } }) -const agreeToGPD = mdf( +const agreeToGPD = withSchema( // Agreeing to the GPD is user input z.object({ agree: z.preprocess((v) => v === 'true', z.boolean()) }), )(async ({ agree }) => ({ agreed: agree })) diff --git a/examples/remix/app/domain/users.ts b/examples/remix/app/business/users.ts similarity index 78% rename from examples/remix/app/domain/users.ts rename to examples/remix/app/business/users.ts index a69a1e79..3f6ca269 100644 --- a/examples/remix/app/domain/users.ts +++ b/examples/remix/app/business/users.ts @@ -1,5 +1,5 @@ import * as z from 'zod' -import { makeDomainFunction as mdf } from 'domain-functions' +import { composable } from 'composable-functions' import { makeService } from 'make-service' const jsonPlaceholder = makeService('https://jsonplaceholder.typicode.com') @@ -15,17 +15,17 @@ const userSchema = z.object({ website: z.string(), }) -const listUsers = mdf(z.any())(async () => { +const listUsers = composable(async () => { const response = await jsonPlaceholder.get('/users') return response.json(z.array(userSchema)) }) -const getUser = mdf(z.object({ id: z.string() }))(async ({ id }) => { +const getUser = composable(async ({ id }: { id: string }) => { const response = await jsonPlaceholder.get('/users/:id', { params: { id } }) return response.json(userSchema) }) -const formatUser = mdf(userSchema)((user) => { +const formatUser = composable((user: z.output) => { return { user: { ...user, diff --git a/examples/remix/app/lib/index.ts b/examples/remix/app/lib/index.ts index 51748b06..1eba4f3d 100644 --- a/examples/remix/app/lib/index.ts +++ b/examples/remix/app/lib/index.ts @@ -1,23 +1,38 @@ -import { Cookie, json, TypedResponse } from '@remix-run/node' -import { Result } from 'domain-functions' +import type { Cookie, TypedResponse } from '@remix-run/node' +import { json } from '@remix-run/node' +import { + catchFailure, + Result, + SerializableResult, + composable, + serialize, + fromSuccess, +} from 'composable-functions' /** * Given a Cookie and a Request it returns the stored cookie's value as an object */ -function envFromCookie( - cookie: Cookie, -): (request: Request) => Promise> { - return async (request: Request) => { +const strictReadCookie = composable( + async (request: Request, cookie: Cookie) => { const cookieHeader = request.headers.get('Cookie') - const parsedCookie = (await cookie.parse(cookieHeader)) || {} - return parsedCookie - } -} + const cookieObj = (await cookie.parse(cookieHeader)) as Record< + string, + unknown + > + if (!cookieObj) throw new Error('Cookie not found') -const actionResponse = , X>( - result: T, + return cookieObj + }, +) +const safeReadCookie = catchFailure(strictReadCookie, () => ({})) + +const envFromCookie = fromSuccess(safeReadCookie) + +const actionResponse = ( + result: Result, opts?: RequestInit, -) => json(result, { status: result.success ? 200 : 422, ...opts }) +): TypedResponse> => + json(serialize(result), { status: result.success ? 200 : 422, ...opts }) const loaderResponseOrThrow = >( result: T, diff --git a/examples/remix/app/root.tsx b/examples/remix/app/root.tsx index 21d9acd3..07838390 100644 --- a/examples/remix/app/root.tsx +++ b/examples/remix/app/root.tsx @@ -1,51 +1,57 @@ -import type { - DataFunctionArgs, - LinksFunction, - MetaFunction, -} from '@remix-run/node' -import { json } from '@remix-run/node' import { Form, Links, - LiveReload, Meta, Outlet, Scripts, + ScrollRestoration, useActionData, useLoaderData, useRouteError, } from '@remix-run/react' -import { ScrollRestoration } from '@remix-run/react' -import * as React from 'react' - -import { envFromCookie, loaderResponseOrThrow } from '~/lib' -import { agreeToGPD, cookie, getGPDInfo } from '~/domain/gpd' -import { inputFromForm } from 'domain-functions' +import { + ActionFunctionArgs, + LinksFunction, + LoaderFunctionArgs, +} from '@remix-run/node' -import styles from './tailwind.css' +import styles from './tailwind.css?url' -export const meta: MetaFunction = () => [ - { - charset: 'utf-8', - title: 'Remix Domains', - viewport: 'width=device-width,initial-scale=1', - language: 'en-US', - }, -] +import { actionResponse, envFromCookie, loaderResponseOrThrow } from '~/lib' +import { agreeToGPD, cookie, getGPDInfo } from '~/business/gpd' +import { inputFromForm } from 'composable-functions' export const links: LinksFunction = () => [{ rel: 'stylesheet', href: styles }] -export const loader = async ({ request }: DataFunctionArgs) => { - const result = await getGPDInfo(null, await envFromCookie(cookie)(request)) +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ) +} + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const result = await getGPDInfo(null, await envFromCookie(request, cookie)) return loaderResponseOrThrow(result) } -export const action = async ({ request }: DataFunctionArgs) => { +export const action = async ({ request }: ActionFunctionArgs) => { const result = await agreeToGPD(await inputFromForm(request)) if (!result.success || result.data.agreed === false) { - return json(result) + return actionResponse(result) } - return json(result, { + return actionResponse(result, { headers: { 'Set-Cookie': await cookie.serialize(result.data) }, }) } @@ -55,63 +61,38 @@ export default function App() { const actionData = useActionData() const disagreed = actionData?.success && actionData.data.agreed === false return ( - -
- - {disagreed && ( -

- You are not good for our marketing stuff 😩 -

- )} - {disagreed || agreed || ( -
+ + {disagreed && ( +

+ You are not good for our marketing stuff 😩 +

+ )} + {disagreed || agreed || ( + + Want some 🍪 ? + - - - )} -
- -
- ) -} - -type DocumentProps = { - children: React.ReactNode - title?: string -} -function Document({ children, title }: DocumentProps) { - return ( - - - {title && {title}} - - - - - {children} - - - - - + Agree... I guess + + + + )} + ) } @@ -119,11 +100,9 @@ export function ErrorBoundary() { const error = useRouteError() console.error(error) return ( - -
-

500

-

Server error

-
-
+
+

500

+

Server error

+
) } diff --git a/examples/remix/app/routes/_index.tsx b/examples/remix/app/routes/_index.tsx index 54e74710..20179da2 100644 --- a/examples/remix/app/routes/_index.tsx +++ b/examples/remix/app/routes/_index.tsx @@ -1,17 +1,24 @@ import { LoaderFunctionArgs } from '@remix-run/node' import { Link, useLoaderData, useLocation } from '@remix-run/react' -import { inputFromUrl, map, collect } from 'domain-functions' -import { listColors } from '~/domain/colors' -import { listUsers } from '~/domain/users' +import { inputFromUrl, collect, map, applySchema } from 'composable-functions' +import { listColors } from '~/business/colors' +import { listUsers } from '~/business/users' import { loaderResponseOrThrow } from '~/lib' +import { z } from 'zod' -// We'll run these 2 domain functions in parallel with Promise.all -const getData = collect({ - // The second argument will transform the successful result of listColors, - // we only care about what is in the "data" field - colors: map(listColors, ({ data }) => data), - users: listUsers, -}) +const getData = applySchema( + // We are applying a schema for runtime safety + // By not defining schemas for every composable we avoid unnecessary processing + z.object({ page: z.string().optional() }), +)( + // We'll run these 2 composables in parallel with Promise.all + collect({ + // The second argument will transform the successful result of listColors, + // we only care about what is in the "data" field + colors: map(listColors, ({ data }) => data), + users: listUsers, + }), +) export const loader = async ({ request }: LoaderFunctionArgs) => { // inputFromUrl gets the queryString out of the request and turns it into an object const result = await getData(inputFromUrl(request)) @@ -24,7 +31,7 @@ export default function Index() { const qs = new URLSearchParams(location.search) return ( <> -

Domain Functions

+

Composables