Skip to content

Add first-class lazy class registration - #24

Merged
kburov-sc merged 1 commit into
mainfrom
kburov/lazy-class
Sep 3, 2026
Merged

Add first-class lazy class registration#24
kburov-sc merged 1 commit into
mainfrom
kburov/lazy-class

Conversation

@kburov-sc

@kburov-sc kburov-sc commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Background

providesClass currently reads a class and its dependency metadata while registering the service. With cache-on-first-read module namespaces, that lookup can evaluate modules even when the service is never resolved.

This adds a thunk form that defers class lookup until first resolution while preserving the existing direct-class API and its eager runtime behavior. The thunk defers ts-inject's lookup; it cannot defer evaluation of an ordinary static ESM import, because native ESM evaluates imports before registration code runs.

Change

  • Add lazy class providers across the class-registration surface:
    • Container.providesClass("service", () => Service)
    • PartialContainer.providesClass("service", () => Service)
    • appendClass("services", () => Service)
    • the exported low-level ClassInjectable
  • Cache the first successfully returned constructor so dependency metadata and construction always use the same class.
    • Container reads injectable dependency metadata on first resolution, allowing lazy classes to use the normal service-resolution path.
    • Existing override, self-dependency, service memoization, and eager direct-class behavior remain unchanged.
    • Provider failures retry the provider; after a constructor is returned, dependency or construction failures retry service creation with that cached constructor.
  • Add runtime and type-level coverage for direct and partial containers, composition, Proxy-backed namespace reads, dependency validation, append behavior, overrides, self-dependencies, constructor identity, and error/retry semantics.
  • Extend npm run bench with 10, 100, and 1,000-class chains.
    • A cache-on-first-read Proxy loader records registration-time namespace reads and export evaluations.
    • An already-loaded-class case isolates portable thunk/caching overhead.
    • Results use 5 warmups and medians from 15 samples and are recorded in benchmarks/LAZY_CLASS_RESULTS.md.
    • Lazy registration reduced simulated registration reads and evaluations from one per class to zero. The deferred work appears in cold resolution, while memoized get() performance remains effectively unchanged; ordinary already-loaded classes do not show a general performance win.
  • Bump the library version from 1.0.1 to 1.1.0 for the additive API release.

Test Plan

  • npm test -- --runInBand — 6 suites and 119 tests pass with 100% coverage.
  • npm run compile
  • npm run styleguide
  • npm run bench

@kburov-sc
kburov-sc marked this pull request as ready for review August 21, 2026 06:36
ecoronadosc
ecoronadosc previously approved these changes Aug 21, 2026

@ecoronadosc ecoronadosc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good to me.
I would add a caveat somewhere that this is beneficial for lazy evaluated module loaders, but the common web ones may not be.. however having it is a win regardless.

@kburov-sc

Copy link
Copy Markdown
Collaborator Author

🤖 From AI Assistant — Review 4996399000: This caveat is documented in the README’s “Lazy Class Lookup” section, which contrasts Valdi-style lazy export lookup with ordinary static ESM evaluation. The benchmark results also call out that already-loaded/static ESM classes do not show a general performance win.

@msilivonik-sc

Copy link
Copy Markdown
Collaborator

@kburov-sc I'm conviced that the new class-provider API could be useful not jsut for Valdi :)

Could Injectable.dependencies be read lazily by default, when the service is resolved? This could let us reuse the current implementation and avoid a separate path for lazy class providers.

@kburov-sc

kburov-sc commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator Author

🤖 From AI Assistant — iteration history

Round 1 — daf821f — Unified lazy dependency resolution

Container now reads injectable dependency metadata on first resolution, allowing lazy class providers to share the normal resolution path while retaining override and parent-container self-dependency behavior. Added focused coverage and reran Jest, compilation, style checks, and benchmarks.

Round 2 — f6b58f8 — Generalized lazy-loader documentation

Reframed the README and benchmark documentation around cache-on-first-read module namespaces and lazy export loaders, removing loader-specific coupling while retaining the static ESM caveat. Revalidated Jest, compilation, and style checks.

@kburov-sc

Copy link
Copy Markdown
Collaborator Author

🤖 From AI Assistant — Implemented in daf821f. Container.providesService now reads Injectable.dependencies inside the memoized factory on first resolution, so lazy class providers reuse the normal injectable path. This removes the WeakSet marker and lazy-class-specific branch while preserving override and parent-container self-dependency semantics.

For normal Injectable() usage, dependencies remain a plain array; the observable timing change is limited to custom dynamic dependency properties. All 119 tests pass with 100% coverage, along with compilation and style checks. The rerun benchmark still shows zero namespace reads/evaluations during lazy registration and effectively unchanged memoized get() performance.

@msilivonik-sc msilivonik-sc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question about valdi

Comment thread README.md Outdated
The thunk form defers class lookup through `Container` and `PartialContainer` registration and composition until the
service is first resolved. `appendClass("plugins", () => Plugin)` supports the same form.

This is especially useful with lazy module loaders such as Valdi, where reading an export from a module namespace can

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we want to mention in readme anything about valdi? I feel we started to couple the package to valdi and I don't like that direction. Can we do a cleanup and remove Valdi references?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From AI Assistant — Removed the Valdi-specific wording from the README, benchmark methodology/results, benchmark source comment, and PR description. They now describe the behavior generically in terms of cache-on-first-read module namespaces and lazy export loaders. The API and benchmark remain loader-agnostic; this is documentation/comment cleanup only, and the static ESM caveat remains.

@msilivonik-sc msilivonik-sc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@kburov-sc
kburov-sc merged commit 63cdfff into main Sep 3, 2026
1 check passed
@kburov-sc
kburov-sc deleted the kburov/lazy-class branch September 3, 2026 04:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants