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

Accept plain functions in applySchema and deprecates withSchema #164

Merged
merged 6 commits into from
Jul 15, 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
47 changes: 11 additions & 36 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
- [failure](#failure)
- [fromSuccess](#fromsuccess)
- [success](#success)
- [withSchema](#withschema)
- [Combinators](#combinators)
- [all](#all)
- [branch](#branch)
Expand Down Expand Up @@ -48,17 +47,17 @@
# Constructors

## applySchema
It takes a composable and schemas for the input and context, and returns the same composable with the schemas applied. So the types will be asserted at runtime.
It turns a function or a composition of functions into a `ComposableWithSchema` which will have `unknown` input and context, so the types will be asserted at runtime.

It is useful when dealing with external data, such as API requests, where you want to ensure the data is in the correct shape before processing it.

```ts
const fn = composable((
const fn = (
{ greeting }: { greeting: string },
{ user }: { user: { name: string } },
) => ({
message: `${greeting} ${user.name}`
}))
})

const safeFunction = applySchema(
z.object({ greeting: z.string() }),
Expand All @@ -68,8 +67,10 @@ const safeFunction = applySchema(
)(fn)

type Test = typeof safeFunction
// ^? Composable<(input?: unknown, ctx?: unknown) => { message: string }>
// ^? ComposableWithSchema<{ message: string }>
```
For didactit purposes: `ComposableWithSchema<T> === Composable<(i?: unknown, c?: unknown) => T>`


## composable
This is the primitive function to create composable functions. It takes a function and returns a composable function.
Expand Down Expand Up @@ -150,32 +151,6 @@ expect(result).toEqual({
})
```

## withSchema
It creates a composable with unknown input and context 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 context 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 ContextError('Expected number, received null')
// ],
// }
```

# Combinators

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.
Expand Down Expand Up @@ -208,7 +183,7 @@ For the example above, the result will be:
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 }) => {
const a = applySchema(z.object({ id: z.number() }))(({ id }) => {
return String(id)
})
const b = () => {
Expand Down Expand Up @@ -335,7 +310,7 @@ const fetchAsText = ({ userId }: { userId: number }) => {
return fetch(`https://reqres.in/api/users/${String(userId)}`)
.then((r) => r.json())
}
const fullName = withSchema(
const fullName = applySchema(
z.object({ first_name: z.string(), last_name: z.string() }),
)(({ first_name, last_name }) => `${first_name} ${last_name}`)

Expand Down Expand Up @@ -511,7 +486,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 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.
These functions are better suited for composables with runtime validation, such as those built with `applySchema` since they deal with external data and `applySchema` will ensure type-safety in runtime.

For more details on how to structure your data, refer to this [test file](./src/tests/input-resolvers.test.ts).

Expand Down Expand Up @@ -617,7 +592,7 @@ async (request: Request) => {

# 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.
However, to help with composables with schema, 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.
Expand Down Expand Up @@ -645,7 +620,7 @@ An `ContextError` is a special kind of error that represents an error in the con
It has an optional second parameter that is an array of strings representing the path to the error in the context schema.

```ts
const fn = withSchema(
const fn = applySchema(
z.object({ id: z.number() }),
z.object({
user: z.object({ id: z.string() }),
Expand Down
7 changes: 2 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ A set of types and functions to make compositions easy and safe.
- 🏝️ 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 context, enforcing data integrity only when needed.
- 🕵️‍♂️ Runtime Validation: Use `applySchema` with your favorite parser for optional runtime validation of inputs and context, 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.

Expand Down Expand Up @@ -93,7 +93,7 @@ We can also extend the same reasoning to functions that return promises in a tra
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.

### Adding runtime validation to the Composable
To ensure type safety at runtime, use the `applySchema` or `withSchema` functions to validate external inputs against defined schemas. These schemas can be specified with libraries such as [Zod](https://github.com/colinhacks/zod/) or [ArkType](https://github.com/arktypeio/arktype).
To ensure type safety at runtime, use the `applySchema` function to validate external inputs against defined schemas. These schemas can be specified with libraries such as [Zod](https://github.com/colinhacks/zod/) or [ArkType](https://github.com/arktypeio/arktype).

Note that the resulting `Composable` will have unknown types for the parameters now that we rely on runtime validation.

Expand All @@ -105,9 +105,6 @@ const addAndReturnWithRuntimeValidation = applySchema(
z.number(),
z.number(),
)(addAndReturnString)

// Or you could have defined schemas and implementation in one shot:
const add = withSchema(z.number(), z.number())((a, b) => a + b)
```

For more information and examples, check the [Handling external input](./with-schema.md) guide.
Expand Down
2 changes: 1 addition & 1 deletion examples/arktype/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ This simple example can be a reference to adapt composable-functions to any othe

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). This is our preferred approach and we wrote a [post about it](https://dev.to/seasonedcc/using-arktype-in-place-of-zod-how-to-adapt-parsers-3bd5).
- Create your custom `withSchema` and `applySchema` that will validate your input and context and return a `Result`. Example: [the `withArkSchema` and `applyArkSchema` functions](./src/adapters.ts).
- Create your custom `applySchema` that will validate your input and context and return a `Result`. Example: [the `applyArkSchema` function](./src/adapters.ts).

Check out the [`./src`](./src/) directory to understand how we implemented both approaches with [`arktype`](https://github.com/arktypeio/arktype).
4 changes: 2 additions & 2 deletions examples/arktype/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
"tsx": "^4.15.8"
},
"dependencies": {
"arktype": "2.0.0-dev.26",
"arktype": "2.0.0-dev.29",
"composable-functions": "file:../../npm",
"typescript": "^5.5.2"
"typescript": "^5.5.3"
}
}
38 changes: 19 additions & 19 deletions examples/arktype/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 4 additions & 20 deletions examples/arktype/src/adapters.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import {
ApplySchemaReturn,
composable,
Composable,
ComposableWithSchema,
ContextError,
failure,
InputError,
ParserSchema,
UnpackData,
} from 'composable-functions'
import { type, Type } from 'arktype'

Expand All @@ -31,8 +28,8 @@ function adapt<T extends Type>(schema: T): ParserSchema<T['infer']> {
*/
function applyArkSchema<I, C>(inputSchema?: Type<I>, contextSchema?: Type<C>) {
//
return <R, Input, Context>(
fn: Composable<(input: Input, context: Context) => R>,
return <R, Input extends I, Context extends C>(
fn: (input: Input, context: Context) => R,
) => {
const callable = ((input?: unknown, context?: unknown) => {
const ctxResult = (contextSchema ?? type('unknown'))(context)
Expand All @@ -55,24 +52,11 @@ function applyArkSchema<I, C>(inputSchema?: Type<I>, contextSchema?: Type<C>) {
: []
return failure([...inputErrors, ...ctxErrors])
}
return fn(result as Input, ctxResult as Context)
return composable(fn)(result as Input, ctxResult as Context)
}) as ApplySchemaReturn<I, C, typeof fn>
;(callable as any).kind = 'composable' as const
return callable
}
}

function withArkSchema<I, C>(
inputSchema?: Type<I>,
contextSchema?: Type<C>,
): <Fn extends (input: I, context: C) => unknown>(
fn: Fn,
) => ComposableWithSchema<UnpackData<Composable<Fn>>> {
return (handler) =>
applyArkSchema(
inputSchema,
contextSchema,
)(composable(handler)) as ComposableWithSchema<any>
}

export { adapt, withArkSchema, applyArkSchema }
export { adapt, applyArkSchema }
8 changes: 4 additions & 4 deletions examples/arktype/src/usage.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { composable, withSchema } from 'composable-functions'
import { applyArkSchema, withArkSchema, adapt } from './adapters'
import { applySchema, composable } from 'composable-functions'
import { applyArkSchema, 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(
const withFn = applyArkSchema(
type({ a: 'number' }),
type({ b: 'number' }),
)(({ a }, { b }) => a + b)

const withAdapt = withSchema(adapt(type({ a: 'number', b: 'number' })))(
const withAdapt = applySchema(adapt(type({ a: 'number', b: 'number' })))(
({ a, b }) => a + b,
)

Expand Down
4 changes: 2 additions & 2 deletions examples/remix/app/business/colors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as z from 'zod'
import { withSchema } from 'composable-functions'
import { applySchema } from 'composable-functions'
import { makeService } from 'make-service'

const reqRes = makeService('https://reqres.in/api')
Expand All @@ -22,7 +22,7 @@ const getColor = async ({ id }: { id: string }) => {
return response.json(z.object({ data: colorSchema }))
}

const mutateColor = withSchema(
const mutateColor = applySchema(
z.object({
id: z.string(),
color: z.string().min(1, 'Color is required'),
Expand Down
6 changes: 3 additions & 3 deletions examples/remix/app/business/gpd.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import { z } from 'zod'
import { withSchema } from 'composable-functions'
import { applySchema } from 'composable-functions'
import { createCookie } from '@remix-run/node'

const cookie = createCookie('gpd', {
maxAge: 20, // seconds
})

const getGPDInfo = withSchema(
const getGPDInfo = applySchema(
z.any(),
// The "context" knows there can be cookie information in the Request
z.object({ agreed: z.boolean().optional() }),
)(async (_input, { agreed }) => {
return { agreed }
})

const agreeToGPD = withSchema(
const agreeToGPD = applySchema(
// Agreeing to the GPD is user input
z.object({ agree: z.preprocess((v) => v === 'true', z.boolean()) }),
)(async ({ agree }) => ({ agreed: agree }))
Expand Down
4 changes: 2 additions & 2 deletions examples/remix/app/routes/color.$id.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
import { Form, Link, useActionData, useLoaderData } from '@remix-run/react'
import { withSchema, inputFromForm } from 'composable-functions'
import { applySchema, inputFromForm } from 'composable-functions'
import tinycolor from 'tinycolor2'
import { getColor, mutateColor } from '~/business/colors'
import { actionResponse, loaderResponseOrThrow } from '~/lib'
import { z } from 'zod'

export const loader = async ({ params }: LoaderFunctionArgs) => {
const result = await withSchema(z.object({ id: z.string() }))(getColor)(
const result = await applySchema(z.object({ id: z.string() }))(getColor)(
params,
)
return loaderResponseOrThrow(result)
Expand Down
Loading
Loading