Skip to content

Keyed dependency records for Provider — positional deps are the highest-frequency DX wart #65

Description

@btravers

From a DX review of the constraints this repo holds deliberately.

Every provider declares its dependencies twice — once as a tuple, once as a parameter list — and correctness is by position:

TemporalWorkflowActivities(orderContract, "fulfillOrder")(
  [PlaceOrder, OrderRepository, StockService, ShippingService],
  { sync: (place, repository, stock, shipping) =>  },
)

Reordering the array silently rebinds the parameters. TypeScript catches a swap only when the two service shapes differ, and they routinely do not: two config ports that are both { url: string }, two repositories with the same method set, two value ports over the same primitive. ServiceOf erases the port's brand — sync receives services, not port instances — so the nominal identity that makes the rest of di safe is exactly what is not available at this call.

A keyed form removes the coupling:

({ place: PlaceOrder, stock: StockService }, { sync: ({ place, stock }) =>  })

This costs almost no new type machinery. ServicesOf (packages/di/src/provider.ts:6) is already a homomorphic mapped type:

type ServicesOf<D extends readonly AnyPort[]> = { readonly [K in keyof D]: ServiceOf<D[K]> };

That maps over a record unchanged — ServicesOf<{ place: PlaceOrder }> is { place: ServiceOf<PlaceOrder> }. NeedsOf becomes InstanceType<D[keyof D]> instead of InstanceType<D[number]>. Array.isArray already discriminates arms elsewhere in this file and in both worker starters, so a third arm is the established shape.

Why this ranks first among DX changes: it is paid on every provider anyone writes from here on, in every package and every example. Nest gets named injection free from decorators; this gets it free from destructuring, without reflection and without giving up a single compile-time guarantee.

Secondary benefits worth noting: diffs stop being order-sensitive, a dependency can be added without touching unrelated parameters, and the deps list reads as documentation at the call site.

Acceptance

  • Provider(port) accepts a record of ports alongside the existing tuple, with sync/make/class/acquire receiving a matching record.
  • The positional form stays — this is additive, not a migration.
  • The port-minting helpers that wrap Provider (HttpController, AmqpHandler, TemporalWorkflowActivities, HttpRouter, AmqpHandlers, TemporalActivities) accept it too, or state why they cannot.
  • packages/di/src/*.test-d.ts pins that a record whose values are not ports is refused, and that the services record is typed per key.
  • A decision on whether examples/ migrates: they are the teaching surface, so whichever form they use is the one people copy.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1Should land before 1.0 — real DX cost, or a decision blocking other workenhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions