Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Documentation for new Plain-functions feature #158

Merged
merged 3 commits into from
Jun 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 40 additions & 43 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ const result = await runtimeSafeAdd('1', null)

# Combinators

These combinators are useful for composing composables. They all return another `Composable`, thus allowing further application in more compositions.
These combinators are useful for composing functions. They operate on either plain functions or composables. They all return a `Composable`, thus allowing further application in more compositions.

## all

Expand All @@ -187,9 +187,9 @@ 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 a = ({ id }: { id: number }) => String(id)
const b = ({ id }: { id: number }) => id + 1
const c = ({ id }: { id: number }) => Boolean(id)

const result = await all(a, b, c)({ id: 1 })
// ^? Result<[string, number, boolean]>
Expand All @@ -211,9 +211,9 @@ If any of the constituent functions fail, the `errors` field (on the composite f
const a = withSchema(z.object({ id: z.number() }))(({ id }) => {
return String(id)
})
const b = composable(() => {
const b = () => {
throw new Error('Error')
})
}

const result = await all(a, b)({ id: '1' })
// ^? Result<[string, never]>
Expand All @@ -233,9 +233,9 @@ 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 }) => {
const getIdOrEmail = (data: { id?: number, email?: string }) => {
return data.id ?? data.email
})
}
const findUserById = composable((id: number) => {
return db.users.find({ id })
})
Expand All @@ -259,7 +259,7 @@ For the example above, the result will be:
```
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 a = () => 'a'
const b = composable(() => 'b')
const fn = branch(a, (data) => data === 'a' ? null : b)
// ^? Composable<() => 'a' | 'b'>
Expand Down Expand Up @@ -287,7 +287,7 @@ You can catch an error in a `Composable`, using `catchFailure` which is similar
```typescript
import { composable, catchFailure } from 'composable-functions'

const getUser = composable((id: string) => fetchUser(id))
const getUser = (id: string) => fetchUser(id)
// ^? Composable<(id: string) => User>
const getOptionalUser = catchFailure(getUser, (errors, id) => {
console.log(`Failed to fetch user with id ${id}`, errors)
Expand All @@ -303,9 +303,9 @@ const getOptionalUser = catchFailure(getUser, (errors, id) => {
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 a = () => '1'
const b = () => 2
const c = () => true

const results = await collect({ a, b, c })({})
// ^? Result<{ a: string, b: number, c: boolean }>
Expand All @@ -330,19 +330,17 @@ 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 add = (a: number, b: number) => a + b
const addAndMultiplyBy2 = map(add, sum => sum * 2)
```

This can be useful when composing functions. For example, you might need to align input/output types in a pipeline:

```ts
const fetchAsText = composable(
({ userId }: { userId: number }) =>
fetch(`https://reqres.in/api/users/${String(userId)}`).then((r) =>
r.json(),
),
)
const fetchAsText = ({ userId }: { userId: number }) => {
return 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}`)
Expand All @@ -369,7 +367,7 @@ For the example above, the result will be something like this:
`map` will also receive the input parameters of the composable as arguments:

```ts
const add = composable((a: number, b: number) => a + b)
const add = (a: number, b: number) => a + b
const aggregateInputAndOutput = map(add, (result, a, b) => ({ result, a, b }))
// ^? Composable<(a: number, b: number) => { result: number, a: number, b: number }>
```
Expand All @@ -383,12 +381,12 @@ 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) => {
const increment = (n: number) => {
if (Number.isNaN(n)) {
throw new Error('Invalid input')
}
return n + 1
})
}
const summarizeErrors = (errors: Error[]) =>
[new Error('Number of errors: ' + errors.length)]

Expand All @@ -410,12 +408,11 @@ For the example above, the `result` will be:
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 getUser = ({ id }: { id: number }) => db.users.find({ id })

const getCurrentUser = mapParameters(
getUser,
(_input, user: { id: number }) => [{ id: user.id }]
(_input: unknown, user: { id: number }) => [{ id: user.id }]
)
// ^? Composable<(input: unknown, ctx: { id: number }) => User>
```
Expand All @@ -427,9 +424,9 @@ It will pass the output of a function as the next function's input in left-to-ri
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 a = (aNumber: number) => String(aNumber)
const b = (aString: string) => aString == '1'
const c = (aBoolean: boolean) => !aBoolean

const d = pipe(a, b, c)

Expand All @@ -455,9 +452,9 @@ If one functions fails, execution halts and the error is returned.
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 a = (aNumber: number) => String(aNumber)
const b = (aString: string) => aString == '1'
const c = (aBoolean: boolean) => !aBoolean

const d = sequence(a, b, c)

Expand All @@ -478,8 +475,8 @@ For the example above, the result will be:
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 a = (aNumber: number) => String(aNumber)
const b = (aString: string) => aString === '1'

const c = map(sequence(a, b), ([a, b]) => ({ aString: a, aBoolean: b }))

Expand Down Expand Up @@ -520,7 +517,7 @@ const trackErrors = trace(async (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.
These functions are better suited for composables with runtime validation, such as those built with `withSchema` (or `applySchema`) 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).

Expand Down Expand Up @@ -792,9 +789,9 @@ It is the same as `branch` but it will forward the context to the next composabl
```ts
import { context } from 'composable-functions'

const getIdOrEmail = composable((data: { id?: number, email?: string }) => {
const getIdOrEmail = (data: { id?: number, email?: string }) => {
return data.id ?? data.email
})
}
const findUserById = composable((id: number, ctx: { user: User }) => {
if (!ctx.user.admin) {
throw new Error('Unauthorized')
Expand All @@ -819,9 +816,9 @@ Similar to `pipe` but it will forward the context to the next composable.
```ts
import { context } from 'composable-functions'

const a = composable((aNumber: number, ctx: { user: User }) => String(aNumber))
const b = composable((aString: string, ctx: { user: User }) => aString == '1')
const c = composable((aBoolean: boolean, ctx: { user: User }) => aBoolean && ctx.user.admin)
const a = (aNumber: number, ctx: { user: User }) => String(aNumber)
const b = (aString: string, ctx: { user: User }) => aString == '1'
const c = (aBoolean: boolean, ctx: { user: User }) => aBoolean && ctx.user.admin

const d = context.pipe(a, b, c)

Expand All @@ -834,9 +831,9 @@ Similar to `sequence` but it will forward the context to the next composable.
```ts
import { context } from 'composable-functions'

const a = composable((aNumber: number, ctx: { user: User }) => String(aNumber))
const b = composable((aString: string, ctx: { user: User }) => aString === '1')
const c = composable((aBoolean: boolean, ctx: { user: User }) => aBoolean && ctx.user.admin)
const a = (aNumber: number, ctx: { user: User }) => String(aNumber)
const b = (aString: string, ctx: { user: User }) => aString === '1'
const c = (aBoolean: boolean, ctx: { user: User }) => aBoolean && ctx.user.admin

const d = context.sequence(a, b, c)

Expand Down
50 changes: 36 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,11 @@ npm i composable-functions
```tsx
import { composable, pipe } from 'composable-functions'

const faultyAdd = composable((a: number, b: number) => {
const faultyAdd = (a: number, b: number) => {
if (a === 1) throw new Error('a is 1')
return a + b
})
const show = composable(String)
}
const show = (a: number) => String(a)
const addAndShow = pipe(faultyAdd, show)

const result = await addAndShow(2, 2)
Expand Down Expand Up @@ -116,20 +116,30 @@ For more information and examples, check the [Handling external input](./with-sc

A `Composable` is a function that returns a `Promise<Result<T>>` where `T` is any type you want to return. Values of the type `Result` will represent either a failure (which carries a list of errors) or a success, where the computation has returned a value within the type `T`.

So we can define the `add` and the `toString` functions as a `Composable`:
We can create a `Composable` by wrapping a function with the `composable` method:

```typescript
import { composable } from 'composable-functions'

const add = composable((a: number, b: number) => a + b)
// ^? Composable<(a: number, b: number) => number>
```

Or we can use combinators that evaluate to both plain functions and `Composable` into another `Composable`:

```typescript
import { composable, pipe } from 'composable-functions'

const add = composable((a: number, b: number) => a + b)
// ^? Composable<(a: number, b: number) => number>
const toString = (a: unknown) => `${a}`

const toString = composable((a: unknown) => `${a}`)
// ^? Composable<(a: unknown) => string>
const addAndReturnString = pipe(add, toString)
// ^? Composable<(a: number, b: number) => string>
```

## Sequential composition
Now we can compose them using pipe to create `addAndReturnString`:
We can compose the functions above using pipe to create `addAndReturnString`:

```typescript
import { pipe } from 'composable-functions'
Expand All @@ -150,15 +160,16 @@ const addAndReturnString = pipe(toString, add)
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`.

### Using non-composables (mapping)
### Transforming the output (mapping)

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`:
Sometimes we want to use a simple function to transform the output of another function. Imagine you want to apply a plain old function to the result of `add` when it succeeds.
The function `map` can be used for this:
Comment on lines +163 to +166
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we will eventually have assign map a different raison d'être since there are several cases now where you could just use pipe.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, thought about it yesterday but here follows the differences:

  • pipe accepts many functions
  • map passes the input to the second function
  • map infers the arguments of second function while you've got to declare them in pipe

Having thought about that I believe there are places for both to coexist

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do believe there is, and I find map is probably going to be a very convenient way to transform output. We have, however, to find a good concept to encapsulate the differences you have pointed out to use in the docs.


```typescript
import { map } from 'composable-functions'

const addAndReturnString = map(add, result => `${result}`)
// ^? Composable<(a: number, b: number) => string>
```

## Parallel composition
Expand All @@ -167,16 +178,27 @@ There are also compositions where all functions are excuted in parallel, like `P
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:

```typescript
import { composable, all } from 'composable-functions'
import { all } from 'composable-functions'

const add = composable((a: number, b: number) => a + b)
const mul = composable((a: number, b: number) => a * b)
const add = (a: number, b: number) => a + b
const mul = (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.

If you want to work with records instead of tuples, you can use the `collect` function:

```typescript
import { collect } from 'composable-functions'

const add = (a: number, b: number) => a + b
const mul = (a: number, b: number) => a * b
const addAndMul = collect({ add, mul })
// ^? Composable<(a: number, b: number) => { add: number, mul: number }>
```

## Handling errors
Since a `Composable` always return a type `Result<T>` that might be either a failure or a success, there are never exceptions to catch. Any exception inside a `Composable` will return as an object with the shape: `{ success: false, errors: Error[] }`.

Expand Down Expand Up @@ -211,7 +233,7 @@ See [the errors module](./src/errors.ts) for more details.
You can catch an error in a `Composable` using `catchFailure`, which is similar to `map` but will run whenever the first composable fails:

```typescript
import { composable, catchFailure } from 'composable-functions'
import { catchFailure } from 'composable-functions'

// assuming we have the definitions from the previous example
const getOptionalUser = catchFailure(getUser, (errors, id) => {
Expand Down
12 changes: 6 additions & 6 deletions context.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ To avoid such awkwardness we use context:

```tsx
import { context } from 'composable-functions'
const dangerousFunction = composable(async (input: string, { user } : { user: { name: string, admin: boolean } }) => {
const dangerousFunction = async (input: string, { user } : { user: { name: string, admin: boolean } }) => {
// do something that only the admin can do
})
}

const carryUser = context.pipe(gatherInput, dangerousFunction)
```
Expand All @@ -32,8 +32,8 @@ The context.pipe function allows you to compose multiple functions in a sequence
```ts
import { context } from 'composable-functions'

const a = composable((str: string, ctx: { user: User }) => str === '1')
const b = composable((bool: boolean, ctx: { user: User }) => bool && ctx.user.admin)
const a = (str: string, ctx: { user: User }) => str === '1'
const b = (bool: boolean, ctx: { user: User }) => bool && ctx.user.admin

const pipeline = context.pipe(a, b)

Expand All @@ -53,8 +53,8 @@ The context.sequence function works similarly to pipe, but it returns a tuple co
```ts
import { context } from 'composable-functions'

const a = composable((str: string, ctx: { user: User }) => str === '1')
const b = composable((bool: boolean, ctx: { user: User }) => bool && ctx.user.admin)
const a = (str: string, ctx: { user: User }) => str === '1'
const b = (bool: boolean, ctx: { user: User }) => bool && ctx.user.admin

const sequence = context.sequence(a, b)

Expand Down
6 changes: 3 additions & 3 deletions examples/arktype/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@
"dev": "tsx src/usage.ts"
},
"devDependencies": {
"tsx": "^4.7.2"
"tsx": "^4.15.7"
},
"dependencies": {
"arktype": "2.0.0-dev.22",
"arktype": "2.0.0-dev.26",
"composable-functions": "file:../../npm",
"typescript": "^5.4.5"
"typescript": "^5.5.2"
}
}
Loading
Loading