Skip to content

Commit

Permalink
Merge pull request #114 from seasonedcc/decouple-zod
Browse files Browse the repository at this point in the history
Decouple zod
  • Loading branch information
gustavoguichard authored Sep 18, 2023
2 parents 9231014 + 337ab96 commit dba08de
Show file tree
Hide file tree
Showing 7 changed files with 112 additions and 42 deletions.
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Keep your business logic clean with Domain Functions

Domain Functions helps you decouple your business logic from your controllers, with first-class type inference from end to end.
It does this by enforcing the parameters' types at runtime (through [zod](https://github.com/colinhacks/zod#what-is-zod) schemas) and always wrapping results (even exceptions) into a `Promise<Result<Output>>` type.
It does this by enforcing the parameters' types at runtime (through [Zod](https://github.com/colinhacks/zod#what-is-zod) schemas) and always wrapping results (even exceptions) into a `Promise<Result<Output>>` type.

![](example.gif)

Expand Down Expand Up @@ -960,8 +960,11 @@ To better understand how to structure your data, refer to [this test file](./src
- [How domain-functions improves the already awesome DX of Remix projects](https://dev.to/gugaguichard/how-remix-domains-improves-the-already-awesome-dx-of-remix-projects-56lm)

## FAQ

- I want to use domain-functions in a project that does not have Zod, how can I use other schema validation libraries?
- Although we code against Zod during the library development, any schema validation can be used as long as you are able to create an adapter of the type [`ParserSchema<T>`](./src/types.ts#L183).
- Why are the inputs and the environment not type-safe?
- Short answer: Similar to how zod's `.parse` operates, we won't presume you're providing the right data to the domain function. We will validate it only at runtime. The domain function's inner code won't execute if the input/environment is invalid, ensuring that the data you receive is valid. Once validated, we can also infer the output type. Read more about it in [@danielweinmann 's comment](https://github.com/seasonedcc/domain-functions/issues/80#issuecomment-1642453221).
- 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.
Expand Down
7 changes: 0 additions & 7 deletions scripts/build-npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,6 @@ await build({
deno: true,
undici: true,
},
mappings: {
'https://deno.land/x/[email protected]/mod.ts': {
name: 'zod',
version: '^3.21.4',
peerDependency: true,
},
},
package: {
name: 'domain-functions',
version: pkg.version,
Expand Down
50 changes: 38 additions & 12 deletions src/constructor.test.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,46 @@
import {
describe,
it,
assertEquals,
assertObjectMatch,
describe,
it,
} from './test-prelude.ts'
import { z } from 'https://deno.land/x/[email protected]/mod.ts'

import { mdf } from './constructor.ts'
import {
EnvironmentError,
ResultError,
InputError,
InputErrors,
ResultError,
} from './errors.ts'
import type { DomainFunction, SuccessResult } from './types.ts'
import type { Equal, Expect } from './types.test.ts'

describe('makeDomainFunction', () => {
describe('when it has no input', async () => {
const handler = mdf()(() => 'no input!')
type _R = Expect<Equal<typeof handler, DomainFunction<string>>>
describe('when it has no input', () => {
it('uses zod parser to create parse the input and call the domain function', async () => {
const handler = mdf()(() => 'no input!')
type _R = Expect<Equal<typeof handler, DomainFunction<string>>>

assertEquals(await handler(), {
success: true,
data: 'no input!',
errors: [],
inputErrors: [],
environmentErrors: [],
assertEquals(await handler(), {
success: true,
data: 'no input!',
errors: [],
inputErrors: [],
environmentErrors: [],
})
})

it('fails gracefully if gets something other than undefined', async () => {
const handler = mdf()(() => 'no input!')
type _R = Expect<Equal<typeof handler, DomainFunction<string>>>

assertEquals(await handler('some input'), {
success: false,
errors: [],
inputErrors: [{ path: [], message: 'Expected undefined' }],
environmentErrors: [],
})
})
})

Expand All @@ -46,6 +60,18 @@ describe('makeDomainFunction', () => {
})
})

it('fails gracefully if gets something other than empty record', async () => {
const handler = mdf()(() => 'no input!')
type _R = Expect<Equal<typeof handler, DomainFunction<string>>>

assertEquals(await handler(undefined, ''), {
success: false,
errors: [],
inputErrors: [],
environmentErrors: [{ path: [], message: 'Expected an object' }],
})
})

it('returns error when parsing fails', async () => {
const parser = z.object({ id: z.preprocess(Number, z.number()) })
const handler = mdf(parser)(({ id }) => id)
Expand Down
52 changes: 35 additions & 17 deletions src/constructor.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { z } from 'https://deno.land/x/[email protected]/mod.ts'

import {
EnvironmentError,
InputError,
Expand All @@ -8,7 +6,7 @@ import {
} from './errors.ts'
import { schemaError, toErrorWithMessage } from './errors.ts'
import { formatSchemaErrors } from './utils.ts'
import type { DomainFunction, Result } from './types.ts'
import type { DomainFunction, ParserSchema, Result } from './types.ts'

/**
* A functions that turns the result of its callback into a Result object.
Expand Down Expand Up @@ -89,22 +87,17 @@ async function safeResult<T>(fn: () => T): Promise<Result<T>> {
* return { message: `${greeting} ${user.name}` }
* })
*/
function makeDomainFunction<
Schema extends z.ZodTypeAny,
EnvSchema extends z.ZodTypeAny,
>(inputSchema?: Schema, environmentSchema?: EnvSchema) {
return function <Output>(
handler: (
input: z.infer<Schema>,
environment: z.infer<EnvSchema>,
) => Output,
) {
function makeDomainFunction<I, E>(
inputSchema?: ParserSchema<I>,
environmentSchema?: ParserSchema<E>,
) {
return function <Output>(handler: (input: I, environment: E) => Output) {
return function (input, environment = {}) {
return safeResult(async () => {
const envResult = await (
environmentSchema ?? z.object({})
environmentSchema ?? objectSchema
).safeParseAsync(environment)
const result = await (inputSchema ?? z.undefined()).safeParseAsync(
const result = await (inputSchema ?? undefinedSchema).safeParseAsync(
input,
)

Expand All @@ -118,10 +111,35 @@ function makeDomainFunction<
: formatSchemaErrors(envResult.error.issues),
})
}
return handler(result.data, envResult.data)
return handler(result.data as I, envResult.data as E)
})
} as DomainFunction<Awaited<Output>>
}
}

export { safeResult, makeDomainFunction, makeDomainFunction as mdf }
const objectSchema: ParserSchema<Record<PropertyKey, unknown>> = {
safeParseAsync: (data: unknown) => {
if (Object.prototype.toString.call(data) !== '[object Object]') {
return Promise.resolve({
success: false,
error: { issues: [{ path: [], message: 'Expected an object' }] },
})
}
const someRecord = data as Record<PropertyKey, unknown>
return Promise.resolve({ success: true, data: someRecord })
},
}

const undefinedSchema: ParserSchema<undefined> = {
safeParseAsync: (data: unknown) => {
if (data !== undefined) {
return Promise.resolve({
success: false,
error: { issues: [{ path: [], message: 'Expected undefined' }] },
})
}
return Promise.resolve({ success: true, data })
},
}

export { makeDomainFunction, makeDomainFunction as mdf, safeResult }
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ export type {
ErrorWithMessage,
Last,
MergeObjs,
ParserIssue,
ParserResult,
ParserSchema,
Result,
SchemaError,
SuccessResult,
Expand Down
29 changes: 29 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,32 @@ type Last<T extends readonly unknown[]> = T extends [...infer _I, infer L]
*/
type AtLeastOne<T, U = { [K in keyof T]: Pick<T, K> }> = Partial<T> & U[keyof U]

/**
* A parsing error when validating the input or environment schemas.
* This will be transformed into a `SchemaError` before being returned from the domain function.
* It is usually not visible to the end user unless one wants to write an adapter for a schema validator.
*/
type ParserIssue = { path: PropertyKey[]; message: string }

/**
* The result of input or environment validation.
* See the type `Result` for the return values of domain functions.
* It is usually not visible to the end user unless one wants to write an adapter for a schema validator.
*/
type ParserResult<T> =
| {
success: true
data: T
}
| { success: false; error: { issues: ParserIssue[] } }

/**
* The object used to validate either input or environment when creating domain functions.
*/
type ParserSchema<T extends unknown = unknown> = {
safeParseAsync: (a: unknown) => Promise<ParserResult<T>>
}

export type {
AtLeastOne,
DomainFunction,
Expand All @@ -166,6 +192,9 @@ export type {
ErrorWithMessage,
Last,
MergeObjs,
ParserIssue,
ParserResult,
ParserSchema,
Result,
SchemaError,
SuccessResult,
Expand Down
6 changes: 2 additions & 4 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { z } from 'https://deno.land/x/[email protected]/mod.ts'
import type { MergeObjs, ParserIssue, Result, SchemaError, SuccessResult } from './types.ts'

import type { MergeObjs, Result, SchemaError, SuccessResult } from './types.ts'

function formatSchemaErrors(errors: z.ZodIssue[]): SchemaError[] {
function formatSchemaErrors(errors: ParserIssue[]): SchemaError[] {
return errors.map((error) => {
const { path, message } = error
return { path: path.map(String), message }
Expand Down

0 comments on commit dba08de

Please sign in to comment.