Skip to content

Core concept

Partial Application

Bind module dependencies incrementally with the chainable provide method.

Every module has a .provide() method. It binds some dependency keys and returns another callable module whose type contains only the remaining keys. Bound values take runtime precedence even if a structurally wider root variable still contains the same keys. A partial may be any non-null object; .provide() consumes its own string and symbol keys and ignores inherited properties.

Bind values

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

const Calculator = Module<{ x: number; y: number }>()(
  'Calculator',
  ({ x, y }) => x + y,
);
type CalculatorLive = typeof Calculator.Live;

const AddFive = Calculator.provide({ x: 5 });
type AddFiveLive = typeof AddFive.Live;

console.log(AddFive({ y: 3 })); // 8
console.log(AddFive({ y: 10 })); // 15
console.log(AddFive.name); // "Calculator"

The returned callable retains the original declared name.

Chain .provide()

Each call removes the newly bound keys, so partial application can be built in stages.

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

const Expression = Module<{ a: number; b: number; c: number }>()(
  'Expression',
  ({ a, b, c }) => a * b + c,
);
type ExpressionLive = typeof Expression.Live;

const WithA = Expression.provide({ a: 2 });
type WithALive = typeof WithA.Live;

const WithAB = WithA.provide({ b: 3 });
type WithABLive = typeof WithAB.Live;

const ReadyExpression = WithAB.provide({ c: 4 });
type ReadyExpressionLive = typeof ReadyExpression.Live;

console.log(ReadyExpression()); // 10
console.log(ReadyExpression.name); // "Expression"

TypeScript accepts known keys with compatible values and updates the callable signature after every step based on the supplied partial type.

Bind a provider callable

A dependency can be bound to its final value or to a branded provider created by Module or makeModule. Arbitrary functions remain ordinary dependency values; branded callables always participate in DI resolution. Therefore, for a function-valued dependency, a branded provider must return that function rather than merely having a matching callable signature. A module returned from .provide() keeps the provider marker, so it can be nested inside another module. In a discriminated union, a branch-specific provider is accepted after the same partial selects a compatible branch, or after an earlier .provide() call narrows to that branch.

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

const Prefix = Module<{ prefix: string }>()('Prefix', ({ prefix }) => prefix);
type PrefixLive = typeof Prefix.Live;

const BoundPrefix = Prefix.provide({ prefix: 'ready' });
type BoundPrefixLive = typeof BoundPrefix.Live;

const Uppercase = Module<BoundPrefixLive>()('Uppercase', ({ Prefix }) =>
  Prefix.toUpperCase(),
);
type UppercaseLive = typeof Uppercase.Live;

const ReadyUppercase = Uppercase.provide({ Prefix: BoundPrefix });
type ReadyUppercaseLive = typeof ReadyUppercase.Live;

console.log(ReadyUppercase()); // "READY"

Each configured callable owns its bound dependencies. With cache: 'run' or cache: 'none', two variants created from the same base module can bind different values without leaking them into each other, while unresolved dependencies can still use the parent run context. With cache: 'module', the factory-wide cache is keyed by module name, so same-name variants intentionally share whichever result was cached first until flushCache().

Live keeps transitive requirements

Providing a nested module’s keyed output does not automatically remove that module’s transitive dependencies from a parent Live type. Those requirements are explicit fields and must be provided at the parent as well.

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

const Database = Module()('Database', () => ({
  findUser: (id: number) => ({ id, name: 'User ' + id }),
}));
type DatabaseLive = typeof Database.Live;

const UserRepository = Module<DatabaseLive>()(
  'UserRepository',
  ({ Database }) => ({
    findById: (id: number) => Database.findUser(id),
  }),
);
type UserRepositoryLive = typeof UserRepository.Live;

const UserService = Module<UserRepositoryLive>()(
  'UserService',
  ({ UserRepository }) => ({
    displayName: (id: number) => UserRepository.findById(id).name,
  }),
);
type UserServiceLive = typeof UserService.Live;

const BoundRepository = UserRepository.provide({ Database });
type BoundRepositoryLive = typeof BoundRepository.Live;

// Binding only UserRepository still leaves Database in UserRepositoryLive.
const ServiceWithRepository = UserService.provide({
  UserRepository: BoundRepository,
});
type ServiceWithRepositoryLive = typeof ServiceWithRepository.Live;

const ReadyService = ServiceWithRepository.provide({ Database });
type ReadyServiceLive = typeof ReadyService.Live;

console.log(ReadyService().displayName(1)); // "User 1"

This explicitness is intentional: typeof UserRepository.Live contains both DatabaseLive and the keyed UserRepository result, so the composition root can always see the full graph contract. Bind all of those fields when you want a zero-argument callable.

Summary

  • .provide({ key: value }) binds produced values.
  • .provide({ key: provider }) binds module/provider callables.
  • .provide() is chainable, and every returned callable retains its declared name.
  • .provide() tracks remaining fields and supports narrowing discriminated-union dependencies as values are bound.
  • Any non-null object can be a partial; only its own keys are bound.