Skip to content

Latest commit

 

History

History
300 lines (242 loc) · 15.3 KB

migrating-df.md

File metadata and controls

300 lines (242 loc) · 15.3 KB

Migrating from domain-functions

If you are coming from domain-functions, you will find that composable-functions is a more flexible and powerful and better typed library. This document will guide you through the migration process.

Benefits of Migrating from domain-functions to composable-functions

  • 🛡️ Enhanced Type Safety: Enjoy robust type-safety during function composition. The improved type-checking mechanisms prevent incompatible functions from being composed, reducing runtime errors and improving code reliability.
  • 🤌 Simplified Function Creation: No need to define schemas. Create composable functions easily and efficiently without the overhead of schema definitions. Work with plain functions in every combinator.
  • 🕵🏽 Runtime Validation: Use the applySchema function for optional runtime validation of inputs and context. This provides flexibility to enforce data integrity when needed without mandating it for every function. Assuming you have a big chain of composables you can use applySchema to run your runtime validation only once avoiding unnecessary processing.
  • 🔀 Flexible Compositions: The new combinators, such as withContext.pipe, withContext.sequence, and withContext.branch, offer powerful ways to manage typed context which are contextual information across your compositions.
  • 🛠️ Incremental Migration: Seamlessly migrate your existing codebase incrementally. Both domain-functions and composable-functions can coexist, allowing you to transition module by module.
  • 🛟 Enhanced Combinators: New and improved combinators like map, mapParameters, mapErrors and catchFailure provide more control over error handling and transformation, making your code more resilient.

Table of contents

First steps

The first thing you want to know is that the old DomainFunction<T> is equivalent to Composable<(input?: unknown, context?: unknown) => T> (AKA ComposableWithSchema<T>). We brought the arguments to the type signature so we could type check the compositions. A commonly requested feature in domain-functions.

A composable does not need a schema, but you can still use one for runtime assertion. What we used to call a Domain Function is now a Composable with context and a schema.

The new constructor applySchema will work almost exactly as makeDomainFunction, except for the Result type of the resulting function.

The new Result type - Success<T> | Failure

We removed the inputErrors and environmentErrors from the result and represent all of them using instances of Error.

This allows us to preserve stack traces and use the familiar exception interface. To differentiate inputErrors and environmentErrors - which are now called context errors - you can use the instanceof operator. It also opens up the possibility create any custom error your system needs.

// Old ErrorResult:
{
  success: false,
  errors: [{ message: 'Something went wrong' }],
  inputErrors: [{ message: 'Required', path: ['name'] }],
  environemntErrors: [{ message: 'Unauthorized', path: ['user'] }],
}

// New Failure:
{
  success: false,
  errors: [
    new Error('Something went wrong'),
    new InputError('Required', ['name']),
    new ContextError('Unauthorized', ['user']),
  ],
}

Serialization

The issue with native JS errors is that they lose most information when serialized to JSON.

To solve that, whenever you send a Result over the wire you may use the new serialize helper that will turn your errors into a friendly object format, similar to the old ErrorMessage type:

const serializedResult = JSON.stringify(serialize({
  success: false,
  errors: [new InputError('Oops', ['name'])],
}))

// serializedResult is:
`"{ success: false, errors: [{ message: 'Oops', name: 'InputError', path: ['name'] }] }"`

Combinators which shouldn't be affected

The parallel combinators like all and collect, along with map and fromSuccess should work the same way.

Sequential combinators and the concept of context

The environment we used to have in domain-functions is now called context and it is already built-in the composable's parallel combinators since all arguments are forwarded to every function. For a deeper explanation check the context docs.

When it comes to sequential compositions, however, we need special combinators to preserve the context so they work as the domain-functions' combinators.

Use the sequential combinators from the namespace withContext to keep this familiar behavior.

import { withContext } from 'composable-functions'

const result = withContext.pipe(fn1, fn2)(input, ctx)
// same for `withContext.sequence` and `withContext.branch`

Note: The pipe, sequence, and branch outside of the withContext namespace will not keep the context through the composition.

Modified combinators

map

The map's mapper function now receives all the arguments given to the first function. In domain-functions the mapper would only work with the output of the first df, that limitation is gone therefore we can work with both input and output.

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 }>

mapError

The mapError was renamed to mapErrors function and it now receives and returns an Array<Error> instead of an ErrorData - which was removed. Since the new Failure is much simpler than ErrorResult this change will often lead to simpler code:

// Old DF code:
import { mapError } from 'domain-functions'

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)

// New Composable code:
import { mapErrors, isInputError, isContextError } from 'composable-functions'

const summarizeErrors = (errors: Error[]) =>
  [
    new Error('Number of errors: ' + errors.filter((e) => !isInputError(e) && !isContextError(e)).length,
    new InputError('Number of input errors: ' + errors.filter(isInputError).length),
    new ContextError('Number of context errors: ' + errors.filter(isContextError).length),
  ]

const incrementWithErrorSummary = mapErrors(increment, summarizeErrors)

trace

The trace function would get a function that had result, input, and environment as arguments. Now the only change is that it receives all the arguments given to the function. In domain-functions the arguments were always input and environment but in composable-functions that limitation is gone therefore we can't assure it will always be the same.

const fn = (a: number, b: number, c: number) => a + b + c
const withTrace = trace((...args) => console.log(...args))(fn)
const result = await withTrace(1, 2, 3)
// This will log: [{ success: true, data: 6, errors: [] }, 1, 2, 3]

Removed combinators

first

This function was removed because it had a hazardous behavior. It would return the first successful result, but it would run all functions in parallel which could cause unexpected side effects.

Since we introduced branch we never had a use case for first since we used first solely for adding conditional logic in our compositions which branch does much better. We appreciate any feedback on this decision.

collectSequence

We removed this function because even though ECMAScript guarantees the order of the keys in an object, to get this typing right is very challenging.

If you want similar functionality, you can use sequence and map to give names to the results like so:

// instead of
const df = collectSequence({
  name: nameDf,
  age: ageDf,
})

// you can do
const fn = map(sequence(nameFn, ageFn), ([name, age]) => ({ name, age }))

merge

We also removed the merge function because it was not a total function which meant that later domain-functions could override the previous ones.

You can easily achieve the same result with the map with all and mergeObjects functions like so:

// instead of
const df1 = makeDomainFunction()(() => ({ firstName: 'John' }))
const df2 = makeDomainFunction()(() => ({ lastName: 'Doe' }))
const df = merge(df1, df2)
//    ^? DomainFunction<{ firstName: string, lastName: string }>

// you can do
const fn1 = () => ({ firstName: 'John' })
const fn2 = () => ({ lastName: 'Doe' })
const fn = map(all(fn1, fn2), mergeObjects)

Incremental migration

You don't need to migrate the whole project at once. You can have both libraries in the project and migrate one module at a time.

Choose a module that has fewer dependants and swipe all constructors from makeDomainFunction to applySchema.

If your compositions are using domain functions from other modules, you'll see type errors. You can use the toComposable from [email protected] to avoid having to migrate those modules.

During the migration, you might need to have polymorphic functions that can accept both DomainFunction and Composable or the different Result of both libraries. Check out an example of how to do that:

import type { Result as DFResult } from 'domain-functions'
import { isInputError, isContextError } from 'composable-functions'
import type { Result, SerializableResult } from 'composable-functions'

const isFormError = (result: SerializableResult | Result | DFResult) => {
  if ("inputErrors" in result) {
    return result.inputErrors.length > 0
  }
  return result.errors.some(isInputError)
}

const isEnvError = (result: SerializableResult | Result | CFResult) => {
  if ("environmentErrors" in result) {
    return result.environmentErrors.length > 0
  }
  return result.errors.some(isContextError)
}

Once you finish migrating the other modules you can remove the domain-functions dependency.

Dealing with Failures

  • In the tests, change the result.inputErrors and result.environmentErrors for result.errors. You can also test for the name: InputError or ContextError
// replace this
expect(result.inputErrors).containSubset([{ path: ['name'] }])
// with this
expect(result.errors).containSubset([{ name: 'InputError', path: ['name'] }])
// replace this
if (result.inputErrors.length > 0) {
  return result.inputErrors[0].message
}
// with this
if (result.errors.some(isInputError)) {
  return result.errors.find(isInputError).message
}

Equivalence tables

Constructors

Domain Functions Composable Functions
makeDomainFunction(z.string(), z.number())((input, env) => {}) applySchema(z.string, z.number())((input, ctx) => {})
makeSuccessResult(1) success(1)
makeErrorResult({ errors: [{ message: 'Something went wrong' }] }) failure([new Error('Something went wrong')])
new InputError('required', 'user.name') new InputError('required', ['user', 'name'])
new EnvironmentError('oops', 'user.name') new ContextError('oops', ['user', 'name'])
new InputErrors([{ message: 'oops', path: 'user.name' }]) new ErrorList([new InputError('oops', ['user', 'name'])])
new ResultError({ inputErrors: [{ message: 'oops', path: 'user.name' }] }) new ErrorList([new InputError('oops', ['user', 'name'])])

Combinators

Domain Functions Composable Functions
all(df1, df2) all(fn1, fn2)
collect(df1, df2) collect(fn1, fn2)
merge(df1, df2) map(all(fn1, fn2), mergeObjects)
branch(df1, (res) => res ? null : df2) withContext.branch(fn1, (res) => res ? null : fn2)
-- branch(fn1, (res) => res ? null : fn2) without context
pipe(df1, df2) withContext.pipe(fn1, fn2)
-- pipe(fn1, fn2) without context
sequence(df1, df2) withContext.sequence(fn1, fn2)
-- sequence(fn1, fn2) without context
collectSequence({ name: nameDf, age: ageDf }) map(withContext.sequence(nameDf, ageDf), ([name, age]) => ({ name, age }))
map(df, (o) => ({ result: o })) map(fn, (o) => ({ result: o }))
-- map(fn, (o, ...args) => ({ result: o, args }))
first(df1, df2) -- * read docs above
safeResult(() => { throw new Error('oops') }) composable(() => { throw new Error('oops') })
mapError(df, (result) => ({ inputErrors: [], environmentErrors: [], errors: [{ message: 'Oops' }] })) mapErrors(fn, errors => [new Error('Oops')])
trace(({ result, input, environment }) => console.log({ result, input, environment }))(df) trace((result, ...args) => console.log(result, ...args))(fn)

Type utilities

Domain Functions Composable Functions
DomainFunction<string> ComposableWithSchema<string>
SuccessResult<T> Success<T>
ErrorResult Failure
UnpackData<DomainFunction> UnpackData<Composable>

Runtime code

Domain Functions Composable Functions
{ success: true, data: { name: 'John' }, errors: [], inputErrors: [], environmentErrors: [] } { success: true, data: { name: 'John' }, errors: [] }
{ success: false, errors: [{ message: 'Something went wrong' }], inputErrors: [{ message: 'Required', path: ['name'] }], environemntErrors: [{ message: 'Unauthorized', path: ['user'] }] } { success: false, errors: [new Error('Something went wrong'), new InputError('Required', ['name']), new ContextError('Unauthorized', ['user'])] }
-- with serialize: { success: false, errors: [{ message: 'Something went wrong', name: 'Error', path: [] }, { message: 'Required', name: 'InputError', path: ['name'] }, { message: 'Unauthorized', name: 'ContextError', path: ['user'] }] }
result.inputErrors[0]?.message result.errors.find(isInputError)?.message
result.environmentErrors[0]?.message result.errors.find(isContextError)?.message
result.errors[0]?.exception instanceof CustomError result.errors[0] instanceof CustomError