composable-functions

repository·main·Indexed 20 days ago

https://github.com/seasonedcc/composable-functions

A library for creating type-safe, composable functions that handle asynchronous operations and errors using a Result pattern. It provides utilities for sequential composition via pipe and sequence, parallel composition via all and collect, and error handling through catchFailure and mapErrors. The library supports runtime validation with applySchema and includes input resolvers for web requests, and is compatible with both Node.js and Deno environments.

Tokens
13.7K
Snippets
56
Records
64
Agent score
71%

What's inside composable-functions

  1. What is context in Composable functions?

    main

    In composable-functions, context is a mechanism used to ensure the safety and propagation of constant values across a sequence of compositions. Instead of manually passing a shared dependency (like an authenticated user or a configuration object) through every single function in a chain, you use a Composable that accepts a context object as its second argument.

    A Composable with context follows this type signature: Composable<(input: I, context: C) => O>

    This is particularly useful for scenarios like authorization, where an identity or permission set must be available to every step of a process without being explicitly returned and re-passed by each intermediate function.

  2. What is a Composable?

    main

    A Composable is a function that returns a Promise<Result<T>>. The Result<T> type represents either a success (containing the value of type T) or a failure (containing a list of Error objects).

    You can create a Composable by wrapping a plain function with the composable method, or by using combinators like pipe which work with both plain functions and existing Composables.

    import { composable, pipe } from 'composable-functions'
    
    // Creating a primitive composable
    const add = composable((a: number, b: number) => a + b)
    // ^? Composable<(a: number, b: number) => number>
    
    // Composing with a plain function
    const toString = (a: unknown) => `${a}`
    const addAndReturnString = pipe(add, toString)
    // ^? Composable<(a: number, b: number) => string>
  3. Understand the new Result type (Success<T> | Failure)

    main

    The Result type has been simplified. Instead of separate keys for different error types, all errors are represented as instances of Error within an array. This preserves stack traces and allows for standard exception handling.

    To differentiate between error types, use instanceof with specific error classes like InputError or ContextError (formerly environment errors).

    Old ErrorResult structure:

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

    New Failure structure:

    {
      "success": false,
      "errors": [
        new Error('Something went wrong'),
        new InputError('Required', ['name']),
        new ContextError('Unauthorized', ['user']),
      ],
    }
  4. Forward context in sequential compositions with `withContext`

    main

    In sequential compositions, the 'context' (the second argument) is not automatically passed to subsequent functions. To ensure the context is forwarded through the chain, use the combinators in the withContext namespace.

    • withContext.pipe: Similar to pipe, but forwards the context to every function in the chain.
    • withContext.sequence: Similar to sequence, but forwards the context to every function in the chain.
    • withContext.branch: Similar to branch, but forwards the context to the next composable.
    import { withContext } from 'composable-functions'
    
    const a = (val: number, ctx: { user: User }) => val
    const b = (val: number, ctx: { user: User }) => val && ctx.user.admin
    
    // Context is passed to both a and b
    const d = withContext.pipe(a, b)
    const result = await d(1, { user: { admin: true } })
  5. Quickstart: Compose and execute functions

    main

    You can use pipe to compose functions into a single execution chain. When a function in the chain throws an error, the resulting Composable returns a Result object indicating failure instead of throwing an exception.

    Results follow this shape:

    • Success: { success: true, data: T, errors: [] }
    • Failure: { success: false, errors: Error[] }
    import { composable, pipe } from 'composable-functions'
    
    const faultyAdd = (a: number, b: number) => {
      if (a === 1) throw new Error('a is 1')
      return a + b
    }
    const show = (a: number) => String(a)
    const addAndShow = pipe(faultyAdd, show)
    
    const result = await addAndShow(2, 2)
    /*
    result = {
      success: true,
      data: "4",
      errors: []
    }
    */
    
    const failedResult = await addAndShow(1, 2)
    /*
    failedResult = {
      success: false,
      errors: [<Error object>]
    }
    */
  6. Incrementally migrate from domain-functions to composable-functions

    main

    You can migrate your project module by module rather than all at once.

    1. Identify a target module: Choose a module with fewer dependents.
    2. Replace constructors: Swap all makeDomainFunction calls with applySchema.
    3. Handle cross-module dependencies: If a module being migrated depends on unmigrated domain-functions, use toComposable from domain-functions@3.0 to wrap them.
    4. Handle polymorphic types: During the transition, you may need functions that accept results from both libraries. You can check for errors using isInputError and isContextError to handle the different result shapes.
    5. Cleanup: Once all modules are migrated, remove the domain-functions dependency.
    import type { Result as DFResult } from 'domain-functions'
    import { isInputError, isContextError } from 'composable-functions'
    import type { Result, SerializableResult } from 'composable-functions'
    
    // Example of a polymorphic error checker during migration
    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 | DFResult) => {
      if ("environmentErrors" in result) {
        return result.environmentErrors.length > 0
      }
      return result.errors.some(isContextError)
    }
  7. Unwrap a Result by checking the success property

    main

    The Result type returned by a composable only contains the data property if the operation was successful. To maintain type safety and avoid TypeScript errors, you must first check the success property before accessing data.

    const result = await getUser('123')
    if (!result.success) return notFound()
    
    return result.data
    // result.data is now typed as User
    const result = await getUser('123')
    if (!result.success) return notFound()
    
    return result.data
  8. Migrate from domain-functions to composable-functions

    main

    If you are migrating from domain-functions, note that DomainFunction<T> is now equivalent to Composable<(input?: unknown, context?: unknown) => T> (also known as ComposableWithSchema<T>).

    Key Changes:

    • Type Safety: Arguments are now part of the type signature to allow better type-checking during composition.
    • Schemas: You no longer need to define schemas for every function, though you can still use applySchema for optional runtime validation.
    • Context: The old environment is now called context.
    • Result Type: The error structure has changed from a complex object with inputErrors and environmentErrors keys to a unified Failure type containing an array of Error instances.
    • Incremental Migration: Both libraries can coexist, allowing you to migrate module by module.
  9. Update tests and error handling when migrating to composable-functions

    main

    When moving from domain-functions to composable-functions, the structure of error results changes.

    Updating Tests

    Instead of checking result.inputErrors or result.environmentErrors directly, check the result.errors array. You can filter by the error name (InputError or ContextError) or use containSubset.

    Old (domain-functions):

    expect(result.inputErrors).containSubset([{ path: ['name'] }])

    New (composable-functions):

    expect(result.errors).containSubset([{ name: 'InputError', path: ['name'] }])

    Updating Runtime Error Access

    Use the isInputError and isContextError utility functions to find specific errors within the result.errors array.

    Old (domain-functions):

    if (result.inputErrors.length > 0) {
      return result.inputErrors[0].message
    }

    New (composable-functions):

    if (result.errors.some(isInputError)) {
      return result.errors.find(isInputError).message
    }
    // 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
    }
  10. Use withContext combinators for sequential composition

    main

    In composable-functions, parallel combinators (like all and collect) automatically forward arguments to every function. However, for sequential compositions, you must use the combinators in the withContext namespace to ensure the context is preserved through the chain.

    Warning: Using pipe, sequence, or branch outside of the withContext namespace will cause the context to be lost during composition.

    import { withContext } from 'composable-functions'
    
    // Correct way to preserve context in a sequence:
    const result = withContext.pipe(fn1, fn2)(input, ctx)
    
    // Also available:
    // withContext.sequence
    // withContext.branch
    import { withContext } from 'composable-functions'
    
    const result = withContext.pipe(fn1, fn2)(input, ctx)
  11. Use Composable Functions in Deno

    main

    To use this library in a Deno environment, import the functions directly from deno.land/x. Replace standard Node.js imports with the following URL:

    import { composable } from "https://deno.land/x/composable_functions/mod.ts";
    import { composable } from "https://deno.land/x/composable_functions/mod.ts";