DEV Community

Cover image for Angular DI Is Not Type-Safe, So I Fixed It
Romain Geffrault
Romain Geffrault

Posted on

Angular DI Is Not Type-Safe, So I Fixed It

According to a poll I ran on LinkedIn, there are two main ways people use Angular dependency injection.

  1. Only creating root services, meaning services are singletons shared across the whole application.
  2. Creating local services, meaning they are provided at the component, route, or module level. In that model, root services are only used when data truly needs to be shared across the app.

I also got other answers with slightly different, equally valid perspectives, but the poll was still interesting, even if it only reflects my audience, which is mostly experienced developers.

That leads to a few questions:

  • Why does the first group stick to root services only?
  • Why does the second group prefer local services?

From my own experience, even though I personally prefer the second approach, I also think it is currently dangerous.

If a service is not provided, Angular will fail at runtime and crash that part of the application.

And it is not always obvious, or practical, to cover every missing-provider case with tests whose only goal is to verify that services were provided correctly.

On top of that, some people from the first group told me they had never really gone deeper into the local-services approach.

Is that surprising? Not really.

How can we explain these different DI habits?

I do not know how comfortable you are with Angular's DI system, but I doubt you will be surprised if I say that it is not easy to fully understand once local services enter the picture.

Personally, I struggled with it for a while. Luckily, over time I found some excellent articles on the topic. Thanks to Thomas Laforge and his article How Angular Dependency Injection works under the hood.

Even with experience, I can still make mistakes and forget to provide a service.

Or I can assume it is already provided higher up in the tree, only to discover at runtime that it is not.

To me, that is not a skill issue. It is one of Angular's biggest weaknesses.

The core problem is simple: Angular dependency injection is not type-safe.

Dependencies used by services and components are implicit. They are not tracked.

When you import a component, nothing tells you which services it depends on, or which services those services depend on.

And that is something the second group has learned to live with.

Maybe that has always been part of Angular, but from my point of view it has barely evolved since I started using Angular 8.

I think that explains both why the first group prefers the safety of root services and why Angular DI is still hard to master.

The advantage of the local-services approach

The main benefit of local services is better separation of responsibilities.

They also fit feature-based architecture very well.

And because they follow the lifecycle of a component or a route, they can be very useful when you want cleanup to happen automatically.

Sometimes you want a service to be shared across one feature, but not across the whole application.

In that case, you do not need a global service. You need a feature-local service.

I would even push that idea further.

I prefer keeping things as local as possible.

Logic isolated inside a component => plain function.
Reusable shared logic that does not need DI => still a function.
Feature-specific logic that should be testable or shared with child components => component-local service.
Logic shared across a route scope => route-local service.
And only then => global service.

That gives me an approach where I provide exactly what I need, exactly where I need it, a bit like standalone components.

Personally, I find this approach healthier, even if it still comes with risk because the app can still fail at runtime.

Can Angular dependency injection be made type-safe? How do other tools do it?

The solution that felt obvious to me was to make Angular DI type-safe.

That way, forgetting to provide a service would become a compile-time error.

But how?

I have been reading the Effect documentation for a while, and Effect ships with a native type-safe DI system.

And honestly, it is extremely well designed.

How do they achieve type-safe DI?

They rely on TypeScript generator functions.

const myFn = function* () {
  const myService = yield* MyService; // ~ inject MyService
  const myOtherService = yield* MyOtherService; // ~ inject MyOtherService
  return myService.a + myOtherService.b;
};
Enter fullscreen mode Exit fullscreen mode

In this example, if you look at the signature of myFn, you can clearly see MyService and MyOtherService.

And you can also see that the function returns a number.

So Effect uses this mechanism to track injected dependencies natively.

Those dependencies are preserved all the way until the program runs.

Then, when the app is executed, if all required services have not been explicitly provided, you get a compile-time error and the program cannot run.

const program = Effect.gen(function* () {
  const random = yield* Random;
  const randomNumber = yield* random.next;
  console.log(`random number: ${randomNumber}`);
});

// Providing the implementation
//
//      ┌─── Effect<void, never, never>
//      ▼
const runnable = Effect.provideService(program, Random, {
  next: Effect.sync(() => Math.random()),
});
Enter fullscreen mode Exit fullscreen mode

The syntax may look unusual at first, but it is absolutely approachable for beginners. You do not need deep type-system knowledge to understand it.

That solution brings many other advantages and makes DI type-safe.

So I thought: why not do something similar for Angular?

And that is what I did. I made Angular dependency injection type-safe.

How can Angular dependency injection be made type-safe?

By taking inspiration from Effect, I quickly realized that injecting directly inside a component or service creates an implicit dependency.

And if the dependency is implicit, there is no reliable way to track it.

I split the problem into four steps. This work is still recent, so I have probably missed some edge cases, but the approach already feels solid for common scenarios.

1. Track service dependencies

To track service dependencies, I stopped using Angular decorators like @Injectable because they are too limited for this purpose and do not make it easy to track dependencies or scope.

With regular Angular services, you cannot tell from the type signature whether the service is providedIn: 'root' or not.

So I created a utility called craftService. It accepts a generator function that lets me track injected dependencies.

const { CounterToYield } = craftService(
  { name: "Counter", scope: "toProvide" },
  () =>
    state(0, ({ update }) => ({
      increment: () => update((value) => value + 1),
      decrement: () => update((value) => value - 1),
    })),
);

const { injectCounterExtended, provideCounterExtended } = craftService(
  { name: "CounterExtended", scope: "toProvide" },
  function* () {
    return yield* CounterToYield(undefined, ({ $self, increment }) => ({
      $self,
      incrementCounter: increment,
    }));
  },
);
Enter fullscreen mode Exit fullscreen mode

With this approach, I can preserve and propagate a service's dependencies through injectCounterExtended and CounterToYield.

I can also preserve the scope, which tells me whether the service still needs to be provided.

Testing services created this way becomes much easier as well.

const { CounterToYield } = craftService(
  { name: "Counter", scope: "global" },
  () =>
    state(10, ({ update }) => ({
      increment: () => update((value) => value + 1),
    })),
);

const { injectCounterConsumer, provideCounterConsumer } = craftService(
  { name: "CounterConsumer", scope: "toProvide" },
  function* () {
    const counter = yield* CounterToYield();

    return {
      read: () => counter(),
      increment: () => counter.increment(),
    };
  },
);

const { sut, mocks } = setupCraftServiceTestingByRegister(
  injectCounterConsumer,
  {
    CounterConsumer: provideCounterConsumer(),
    Counter: {
      // type-safe Counter mock
      $self: vi.fn(() => 41),
      increment: vi.fn(),
    },
  },
);

expect(sut.read()).toBe(41);
sut.increment();
expect(mocks.Counter.increment).toHaveBeenCalledTimes(1);
Enter fullscreen mode Exit fullscreen mode

setupCraftServiceTestingByRegister tells me which dependencies exist and which ones still need to be provided.

I can also choose to mock services. In that case, the dependencies of the mocked service no longer need to be provided or mocked again.

My test only declares exactly what it needs.

If a service evolves, the compiler tells me that I need to update the test.

There is still one limitation with this approach: external services that are not based on craftService, such as Angular services or services from third-party libraries. I use another utility called toCraftService to adapt them, but it is more tedious to set up and maintain. Still, that is already much better than nothing.

That was the easy part. You could probably rebuild something similar very quickly with AI.

Now let us move to the hard part: tracking component dependencies.

2. Track component dependencies

This is the real pain point.

Right now, Angular does not expose the dependencies of a component, directive, or pipe in a way that lets us track them natively.

And what counts as a component dependency, exactly?

There are the services injected directly into the component.

But there are also imported components, directives, pipes, and host directives declared in the @Component metadata.

Unfortunately, Angular does not expose all of that in a trackable form.

The ideal solution would be for Angular itself to support this, probably through a generator-based approach rather than decorators.

But that would require deeper changes in the compiler, language service, and framework internals.

So I chose a more pragmatic approach. It is not perfect, but it is quick to implement and reasonably effective: I reflect all those dependencies in an exported type that lives next to the component.

@Component({
  selector: "app-lazy-layout-child",
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [OtherComponent],
  //...
})
export default class LazyLayoutChildComponent {
  readonly injectedParentRouteData = injectDemoCraftLazyLayoutTeamIdData();
  readonly injectedTeamId = injectDemoTeamIdParams();
}

export type GenDeps_LazyLayoutChildComponent = GetDeps<{
  deps: {
    GenDeps_OtherComponent: GenDeps_OtherComponent;
  };
  propertiesDeps: {
    injectedParentRouteData: {
      DemoCraftLazyLayoutTeamIdData: ReturnType<
        typeof injectDemoCraftLazyLayoutTeamIdData
      >;
    };
    injectedTeamId: {
      DemoTeamIdParams: ReturnType<typeof injectDemoTeamIdParams>;
    };
  };
  provided: {};
  publicProperties: GetPublicComponentProperties<LazyLayoutChildComponent>;
  missingProvider: {
    DemoCraftLazyLayoutTeamIdData: ReturnType<
      typeof injectDemoCraftLazyLayoutTeamIdData
    >;
    DemoTeamIdParams: ReturnType<typeof injectDemoTeamIdParams>;
  };
}>;
Enter fullscreen mode Exit fullscreen mode

So I generate the GenDeps_LazyLayoutChildComponent type to reflect every dependency used by the component.

Do I write that by hand? No.

I use an ESLint rule to ensure GenDeps_LazyLayoutChildComponent stays synchronized with the component.

I also added a quick fix to regenerate it automatically.

That said, there is still a weak spot.

As long as GenDeps_LazyLayoutChildComponent is not synchronized with the component, the compiler cannot detect missing or incorrect dependencies.

To me, that is acceptable, because it is still better than the status quo, and linting will keep reporting an error until the type is fixed.

My script also pulls in the GenDeps_X types from imports so that all dependencies remain explicit.

In the future, Angular could support this natively and remove that human-maintained layer.

With this setup, GetDeps computes which services are still missing.

I also keep exposing the full list of services used by the component, which is useful for testing and more.

Once again, the main limitation is third-party components. They can still introduce blind spots. I also have not handled recursive dependency tracking for components yet, but I think that should be solved separately.

With that trick, we can track a component's dependencies and sub-dependencies. The next step is to move upward through the injection tree, all the way to the routes.

3. Track route dependencies

When I import a component, I also force the import of its dependency type GenDeps_X so I can propagate all required dependencies up to the route level.

For that, I created a utility called craftRoutes.

I chose this API because it lets me run type-level computations that simplify dependency management later on.

It also lets me expose route-related injectables such as params or data, which then become tracked dependencies too.

In the example below, I also tagged my routes with a demo scope. I find it useful for identifying where injectables come from, and I may use it later for observability as well, such as logs and traces.

export const {
  demoRoutes,
  injectDemoTeamIdParams,
  injectDemoCraftLazyLayoutTeamIdData,
  injectDemoUserIdParams,
} = craftRoutes("demo", [
  {
    path: "query/:userId",
    componentDeps:
      {} as import("./examples/primitives/query/query").GenDeps_GlobalQuery,
    loadComponent: () => import("./examples/primitives/query/query"),
  },
  // ...
  {
    path: "craft/lazy-layout/:teamId",
    data: {
      someParentRouteData: "foo",
    },
    loadComponent: () => import("./examples/craft/lazy-layout/lazy-layout"),
    componentDeps:
      {} as import("./examples/craft/lazy-layout/lazy-layout").GenDeps_LazyLayoutComponent,
    loadChildren: () =>
      import("./examples/craft/lazy-layout/lazy-layout.routes").then(
        (m) => m.lazyLayoutRoutes,
      ),
  },
]);
Enter fullscreen mode Exit fullscreen mode

With this, demoRoutes carries all missing dependencies while taking providers declared at the route level into account.

For now, I deliberately chose not to preserve dependencies route by route, because TypeScript can saturate pretty quickly and start producing errors.

componentDeps is mandatory, which adds some boilerplate, but as soon as Angular can track component dependencies natively, that part can disappear.

We also should not forget dependencies coming from guards, such as canActivate, canMatch, or resolvers.

That leaves one final step: ensuring that everything required is actually provided when the app starts.

4. Ensure that all required services are actually provided at runtime

The last step is to account for services provided in app.config.ts as well as services provided at the AppComponent level.

I created another utility called craftAppConfig, which once again only surfaces services that are still missing.

export const appConfig = craftAppConfig({
  appStart: {
    AppStartLog: injectAppStartLog,
  },
  routingDeps: demoRoutes.META_DATA,
  providers: [
    provideHttpClient(),
    provideBrowserGlobalErrorListeners(),
    provideCraftRouter(demoRoutes.toRoutes(), withComponentInputBinding()),
  ],
});
Enter fullscreen mode Exit fullscreen mode

The final resolution happens in main.ts, at bootstrapApplication time.

That is where I add the final piece that verifies everything is consistent.

bootstrapApplication(App, toApplicationConfig(appConfig)).catch((err) =>
  console.error(err),
);

type CheckAppDI = AppCheckedDI<GenDeps_App, AppRouteMetaDataForDi>;

type _CanRun = CanRun<CheckAppDI>;
Enter fullscreen mode Exit fullscreen mode

I add AppCheckedDI and CanRun.

Right now, AppCheckedDI returns true when everything is valid, and CanRun expects that value to be true.

If a dependency is still missing, AppCheckedDI returns a list describing the missing services.

And CanRun produces a type error because it expects CheckAppDI to resolve to true.

That blocks compilation and shows an explicit error.

Example:

 [ERROR] TS2344: Type '["Injected Counter is not provided in path: \"craft-service/counter\""]' does not satisfy the constraint 'true'. [plugin angular-compiler]

    apps/demo/src/main.ts:16:22:
      16  type _CanRun = CanRun<CheckAppDI>;
Enter fullscreen mode Exit fullscreen mode

I wanted a type-safe DI system, and this gets me there.

The limitations of this type-safe DI system

  1. As mentioned earlier, we still need to create a type that reflects the dependencies of each component, pipe, or directive, and keep it synchronized through linting. So errors are still possible, even if they are much less likely to reach production.

Because lint errors, while clearly visible in the IDE, do not block the compiler by themselves.

  1. Third-party services, components, directives, and pipes remain a likely source of mistakes. Even if they can be adapted into the tracking system, you still need to stay careful when they evolve.

  2. On large projects, the system may hit limits. We may need intermediate strategies to keep the idea scalable. Faster linting tools such as Biome might also help compared to ESLint.

  3. This system works very well in applications built around standalone components. In applications that rely heavily on large SharedModules, it is less effective, especially when those modules are large. I do not have that problem right now, but it will require a dedicated strategy.

  4. I have not tested this with viewChildren or ViewChild, nor with contentChildren or contentChild yet, but I suspect this is mostly a matter of adjusting the ESLint quick-fix generation.

How can you integrate this system into your application?

Personally, I do not think you need to copy my code and utilities directly, except maybe as inspiration.

With AI, I think you could build something similar very quickly, and maybe even improve on what I did.

The code I showed is strongly coupled to my @craft-ng library, which gives me useful leverage to get the most out of the approach.

It is also still experimental, and I have only covered the cases I needed.

Still, if you want to look at it, here is the repository and branch where I implemented this type-safe DI system in an Angular project.

After that, I asked my AI to build a skill that could help someone else's AI set up this kind of system.

Here it is, although I cannot really say yet how good it is:

Skill to help implement a type-safe DI system for Angular

I also suspect it might help to temporarily bring my demo project into your repository while making the change, although I am not fully sure how effective that would be in practice.

Demo

Unfortunately, StackBlitz does not handle ESLint well, which makes the system harder to showcase properly. That is why I am not sharing a live demo link to test it directly.

Imagine a component that calls service A, and service A depends on service B.

Service A has a function scope, which lets me extract logic out of a component without having to provide the service.

Service B has a global scope.

As soon as I switch service B to toProvide, I immediately get a compile-time error telling me that I now need to provide it.

Agular Type-safe DI

What are the benefits of tracking dependencies across the whole project?

There are many advantages to preserving and exposing the dependencies of your application.

It goes far beyond just detecting DI mistakes before runtime. It can improve the overall quality of the application in several areas.

Here are a few benefits that come to mind:

  • Testing: simpler setup for both component and service tests.
  • If a dependency is added, the compiler tells me that I need to update the test.
  • If a dependency is removed, the compiler also tells me to update the test.
  • It becomes easy to mock only the services that actually interact with the browser.
  • When I mock a service, I no longer need to provide the dependencies of that mocked service.
  • I can detect which services depend on the environment and write the right tests for them.
  • E2E testing: I can know precisely which API calls a page makes. That is useful for mocking data and avoiding real calls.
  • I can detect architecture anomalies in component coupling, like: why is this service being used from this route?
  • Refactoring becomes safer and encourages writing tests before changing code. If I forget something, the compiler helps me.
  • On a large and messy application, you could introduce this system without changing runtime behavior or business logic, add tests, and then refactor with much more confidence.
  • Architecture-wise, it encourages creating local services exactly where they belong, and widening their scope only when needed.
  • Confidence: your app evolves, which is normal, but if you forget to provide a service, you get a compile-time error instead of a nasty runtime surprise.
  • Observability: you can drive logging or tracing based on the services used by a route, component, or service. That can be extremely useful for monitoring and debugging.
  • You can track route inputs such as params and data in a type-safe way. If you get a name wrong, the compiler tells you.
  • It also opens the door to type-safe routing, with autocompletion and safer relative navigation.
  • Resolvers can become much more powerful at the route level: display the main component once data is ready, a loading component while it is pending, and an error component if something fails, all with strict typing.
  • Query params can be defined at the route level and injected into components in a type-safe way.
  • AI gets fast feedback if it breaks Angular's DI rules.
  • You can create abstract services during development and implement them later without the risk of forgetting to provide the real implementation.
  • You can make sure services meant to run through provideAppInitializer are actually provided, and avoid the errors that usually come with that.
  • The same idea helps in tests too: if a service must run during app startup, you can explicitly choose whether it should run before a test or be ignored.
  • Angular DI becomes easier to learn because feedback is immediate when an injection rule is broken.

I am probably forgetting many others. But even from this list, you can see how simply tracking application dependencies reveals much more of what Angular's DI system can offer.

Conclusion

Setting up a type-safe DI system for Angular means you no longer have to stay trapped in an architecture built only around global services, even if that choice is perfectly understandable without type safety.

As I already mentioned, it also makes Angular DI easier to learn and easier to adopt, even for less experienced developers who are afraid of making mistakes.

What I like most is that I added almost no runtime logic. Most of the work happens at the type level, which avoids bloating the final bundle.

And I really hope Angular will eventually offer a native solution for type-safe dependency injection, because that would remove all the boilerplate I currently need to add.

I would be curious to know what you think about all this, and whether you have already built a system to track the dependencies in your own app.

Or whether you are considering it.

I will repeat it one last time: even if you are not comfortable with advanced typing, this kind of system is surprisingly easy to put in place with AI, especially if your only goal is to track missing DI providers.

You could also keep using Angular's standard services and apply the same dependency-tracking technique only to component dependencies.

On my side, I am convinced this approach can genuinely improve the quality of Angular applications.

Even though I still have not seen or read any article on this specific topic, I hope this one can inspire others to explore the same path, and maybe even contribute to a native Angular solution someday.

It highlights all the work the Angular team has already done on DI, and it lets me get much more value from that system.

I still want to run more experiments before using this on production projects, but the foundation is now there.

If you have thoughts on it, feel free to tell me in the comments or contact me directly.

P.S. If you are up for it, should we attack the Angular compiler next and make component dependency tracking native? Want to talk about it?


I am Romain Geffrault.
Angular developer and creator of @craft-ng
Follow me for more Angular content

Docs: https://ng-angular-stack.github.io/craft/

Top comments (0)