Skip to content

Advanced

Transform Output

Inspect or transform successful module results at runtime and with HKT typing.

transformOutput runs after a module callback returns. It receives (result, deps, isRoot):

  • result is the callback’s returned value;
  • deps is the value produced by transformInput;
  • isRoot is true for a direct call and false when the module is being resolved as another module’s provider.

Observe successful results

The standard input transform still applies when only transformOutput is customized, so the transformed dependency context contains the current invocation’s Module.name at runtime. A nested provider gets its own metadata view and cannot overwrite the outer transform’s name.

import { makeModule, type ModuleLive } from '@favy/di';

const TracedModule = makeModule({
  transformOutput: (result, deps, isRoot) => {
    const name = (deps as unknown as ModuleLive).Module.name;
    console.log(name.toString() + ' root=' + isRoot + ' result=' + result);
    return result;
  },
});

const Sum = TracedModule<{ a: number; b: number }>()(
  'Sum',
  ({ a, b }) => a + b,
);
type SumLive = typeof Sum.Live;

console.log(Sum({ a: 2, b: 3 })); // 5

Use isRoot when root calls and internally resolved providers need different logging, metadata, or validation.

Callback errors are not intercepted

The module callback is evaluated before transformOutput is called. If the callback throws synchronously, the transform never receives a result and cannot catch that error.

import { makeModule } from '@favy/di';

let transformed = 0;

const ObservedModule = makeModule({
  transformOutput: (result) => {
    transformed += 1;
    return result;
  },
});

const Fails = ObservedModule()('Fails', () => {
  throw new Error('callback failed');
});
type FailsLive = typeof Fails.Live;

try {
  Fails();
} catch {
  console.log('caught by the caller');
}

console.log(transformed); // 0

Handle callback failures in the callback itself or around the module call. transformOutput can validate a successfully returned value and may throw its own error.

An output transform can return an object whose methods catch errors when called. The API result example below wraps an async Load() method to handle both thrown errors and rejected promises.

Ordinary transforms define a concrete output type

For an ordinary transformOutput<I, D, O>, the module callback must return a value assignable to I, and the created callable returns O. This keeps a concrete runtime transformation aligned with its TypeScript type.

import { makeModule } from '@favy/di';

const StringBoxModule = makeModule({
  transformOutput: (result: string) => ({ value: result }),
});

const Greeting = StringBoxModule()('Greeting', () => 'hello');
type GreetingLive = typeof Greeting.Live;
const box = Greeting();

const message: string = box.value;
console.log(message); // "hello"

Because this transform accepts string, a module callback returning an object or number is rejected. For observation that returns the input unchanged, use a generic identity transform; the callable then preserves each callback’s inferred result type:

const ObservedModule = makeModule({
transformOutput: <T>(result: T) => {
console.log(result);
return result;
},
});

Use an HKT for a static result transformation

Return an HKT marker when the output type must be calculated generically from the module name, callback result, or remaining dependencies. An output HKT must produce a callable type; TModule can preserve the normal callable shape while replacing its result.

import { makeModule, type HKT, type ModuleLive, type TModule } from '@favy/di';

type Box<Name, Result> = {
  value: Result;
  name: Name;
};

type BoxedModule<Name extends PropertyKey, Result, Deps> = TModule<
  Name,
  Deps,
  Box<Name, Result>
>;

interface BoxHKT extends HKT {
  readonly type: BoxedModule<this['_NAME'], this['_RESULT'], this['_DEPS']>;
}

const BoxModule = makeModule({
  transformOutput: (result, deps) =>
    ({
      value: result,
      name: (deps as unknown as ModuleLive).Module.name,
    } as unknown as BoxHKT),
});

const Greeting = BoxModule()('Greeting', () => 'hello');
type GreetingLive = typeof Greeting.Live;
const output = Greeting();

console.log(`${output.name}: ${output.value}`); // "Greeting: hello"

Here this['_RESULT'] becomes the callback return type, this['_NAME'] becomes the declared key, and this['_DEPS'] preserves the callable’s remaining dependencies. The runtime object and the statically computed result now have the same shape.

Return typed API results

An application can use one result shape for API calls while keeping each endpoint’s payload type. The following custom ApiModule factory wraps a module’s async Load() method in try/catch: a fulfilled value becomes { ok: true, value }, and a thrown error or rejection becomes { ok: false, error }. $.User.Load() returns Promise<Result<UserData>>; $.Orders.Load() returns Promise<Result<Order[]>>.

The example uses an in-memory API so it can run without a server. Supply another implementation at the same dependency boundary. The tabs separate the reusable factory (api-module.ts), API types (api-types.ts), endpoint modules (loaders.ts), application module (main.ts), and mocks and startup (app.ts).

Main composes UserLive and OrdersLive and awaits $.User.Load() and $.Orders.Load() on the objects supplied by DI. The call in app.ts is the composition root: it supplies the API and providers and runs Main once. If loading the user fails, Main returns early without resolving Orders.

Press Run to execute app.ts using the current code from all five tabs. The console prints Alex and 1.

api-module.ts
import { makeModule, type HKT, type TModule } from '@favy/di';

export type Result<T> =
  | { ok: true; value: T }
  | { ok: false; error: unknown };

interface ApiHKT extends HKT {
  readonly type: this['_RESULT'] extends { Load(): infer Value }
    ? TModule<
        this['_NAME'],
        this['_DEPS'],
        { Load(): Promise<Result<Awaited<Value>>> }
      >
    : never;
}

export const ApiModule = makeModule({
  transformOutput: (service: { Load(): Promise<unknown> }) =>
    ({
      async Load(): Promise<Result<unknown>> {
        try {
          return { ok: true, value: await service.Load() };
        } catch (error: unknown) {
          return { ok: false, error };
        }
      },
    } as unknown as ApiHKT),
});
api-types.ts
export type UserData = { id: number; name: string };
export type Order = { id: number; total: number };

export type Api = {
  getUser(): Promise<UserData>;
  getOrders(): Promise<Order[]>;
};
loaders.ts
import { ApiModule } from './api-module';
import type { Api } from './api-types';

export const User = ApiModule<{ api: Api }>()('User', ({ api }) => ({
  Load: async () => api.getUser(),
}));
export type UserLive = typeof User.Live;

export const Orders = ApiModule<{ api: Api }>()('Orders', ({ api }) => ({
  Load: async () => api.getOrders(),
}));
export type OrdersLive = typeof Orders.Live;
main.ts
import { Module } from '@favy/di';
import type { UserLive, OrdersLive } from './loaders';

export const Main = Module<UserLive & OrdersLive>()('Main', async ($) => {
  const user = await $.User.Load();
  if (!user.ok) {
    if (user.error instanceof Error) console.log(user.error.message);
    return;
  }
  console.log(user.value.name); // Alex: value is UserData

  const orders = await $.Orders.Load();
  if (orders.ok) console.log(orders.value.length); // 1: value is Order[]
});
export type MainLive = typeof Main.Live;
app.ts
import { Main } from './main';
import { User, Orders } from './loaders';
import type { Api } from './api-types';

const api: Api = {
  getUser: async () => ({ id: 1, name: 'Alex' }),
  getOrders: async () => [{ id: 10, total: 49 }],
};

void Main({ api, User, Orders }); // Alex, 1

The module callback returns an object with an async Load method, such as { Load: async () => api.getOrders() }. The HKT extracts that method’s return type with infer Value, unwraps its payload with Awaited, and changes the method’s return type to Promise<Result<Awaited<Value>>>. It preserves the module name and dependency contract; .provide() still accepts a replacement API client. The cast to ApiHKT connects the runtime implementation to this type rule—it does not validate the implementation for you.

transformOutput creates the wrapper without calling Load. Each Load() call invokes and awaits the original method inside try/catch; DI caches the module object, not the method’s results. A throw while creating the original object still escapes before the transform runs. After awaiting Load(), callers narrow ok before accessing value or error.

transformInput comparison

FeaturetransformInputtransformOutput
TimingBefore the module callbackAfter a successful callback return
Runtime valueCallback dependenciesCallback result
Arguments(deps, name)(result, transformedDeps, isRoot)
Concrete static rewritePreserves input and adds compatible fieldsUses I → O directly
Generic static rewriteRequires an input HKTRequires an output HKT