#Typesafe Dependency Injection

94 messages · Page 1 of 1 (latest)

proper solar
#

Hey guys,
I am diving deeper into the typescript type system and thought that a custom typesafe dependency injection system would be the perfect starter project.
To get into the topic I have already done a little research (https://dev.to/vad3x/typesafe-almost-zero-cost-dependency-injection-in-typescript-112, https://github.com/nicojs/typed-inject).
I have come up with something that works a little like nestjs dependency injection and is heavily inspired by the inner workings of elysiajs.

To illustrate how it should work in the end I have setup this snippet:

 const DatabaseService = new Service({
  name: "DatabaseService",
  description: "Database service which handles all database connections",
}).fn("doSomething", () => () => {
  console.log("Hello World from Database Service!");
});

const ContactService = new Service({
  name: "ContactService",
  description: "Contact service which handles all contact related actions",
})
  .service(DatabaseService)
  .fn("createContact", ({ services: { DatabaseService } }) => () => {
    console.log("Creating contact...");
    DatabaseService.doSomething();
  });

const ContactModule = new Module({
  name: "ContactModule",
  description: "Contact module which handles all contact related actions",
})
  .service(DatabaseService)
  .service(ContactService);

If I would remove the second last line (.service(DatabaseService)) the following .service() call with ContactService as argument should display a type error stating that the service requires a dependency which is not provided by the module.
Currently I am only able to set the return type to a string literal with the message, buy that of course does not underline the function call as error.

#

The implementation of Module is here:

export class Module<
  Name extends string = "",
  Imports extends {} = {},
  Services extends {} = {},
  Exports extends {} = {}
> {
  _config: ModuleConfig<Name>;
  _imports: Map<string, Module<any, any>> = new Map();
  _services: Map<string, Service<any, any>> = new Map();

  constructor(config: ModuleConfig<Name>) {
    this._config = {
      ...config,
    };
  }

  public import<ImportModule extends Module<any, any, any>>(
    module: ImportModule
  ): Module<
    Name,
    Imports & {
      [name in ImportModule["_config"]["name"]]: ImportModule;
    }
  > {
    this._imports.set(module._config.name, module);
    return this;
  }

  public service<
    AddService extends Service<any, any>,
    ExportService extends boolean = false
  >(
    service: AddService,
    _exportService: ExportService = false as ExportService
  ): AddService extends Service<infer Name, infer Decorators>
    ? Services extends Record<keyof Decorators["services"], any>
      ? ExportService extends true
        ? Module<
            Name,
            Imports,
            Prettify<
              Services & { [name in AddService["_config"]["name"]]: AddService }
            >,
            Prettify<
              Exports & { [name in AddService["_config"]["name"]]: AddService }
            >
          >
        : Module<
            Name,
            Imports,
            Prettify<
              Services & { [name in AddService["_config"]["name"]]: AddService }
            >,
            Exports
          >
      : `Error: Service ${Name} has dependencies which are not provided by the module.`
    : this {
    this._services.set(service._config.name, service);
    return this as any;
  }
}
#

Feel free to ping me if you need more information

#

Thanks ^^

proper solar
#

!helper

dense marlin
#

Can you make a minimal reproduction on TS playground?

proper solar
austere jungleBOT
#
xilalus#0

Preview:```ts
type ServiceConfig<Name extends string = ""> = {
name: Name;
description: string;
};

type DecoratorBase = {
state: {
[x: string]: unknown;
};
functions: {
[x: string]: unknown;
};
services: {
[x: string]: unknown;
};
};

type Prettify<T> = {
...```

dense marlin
#

@proper solar It's a bit long for me to read through, but here's something you can adapt into your solution to perform the check:

austere jungleBOT
#
nonspicyburrito#0

Preview:```ts
declare const requiredServiceCheck: unique symbol

class Service<TName, TRequiredService = never> {
private declare [requiredServiceCheck]: TRequiredService
}

class Module<TContainedService = never> {
service<TName>(
service: Service<TName, TContainedService>
): Module<TContainedService | TN
...```

proper solar
#

!resolved

proper solar
#

@dense marlin sorry to bother you again but would you mind answering another question?

#

I have replaced the Service class and its methods by just using the classes I want to inject dependencies directly. Now I can get the constructor parameters of those functions by using the ConstructorParameters utility type or by infering them. By using T[number] on the resulting array I can get a union type of all of the required dependencies. I still need to check if in a module where this class is added as a service, all of its required dependencies are available.

#

I have tried to use a conditional type to resolve to never if this is not the case but the conditional type does not behave as I have thought it would. Do you have a different idea on how I can overcome this?

austere jungleBOT
#
xilalus#0

Preview:```ts
type Prettify<T> = {
[K in keyof T]: T[K]
} & {}

type Constructable<T> = {
new (...args: any[]): T
} & Function

type Class<A, B extends Array<any>> = {
new (...args: B): A
} & Function

type ConstructableWithParams<
T extends Constructable<any>,
P

= T extends
...```

dense marlin
# austere jungle

You should be able to do the same thing as what I've given here, no?

proper solar
#

The conditional type I currently use to check if the required dependency union type extends the provided union type does not behave as I thought it would

#

In the TS playground above the ServiceThree class requires both ServiceOne and ServiceTwo but the Module does not give an error

dense marlin
proper solar
#

And since decorators can't do that I thought the only other sensible place would be at the Module level

dense marlin
#

Here's a solution:

austere jungleBOT
#
nonspicyburrito#0

Preview:```ts
type ServiceClass = new (...args: never) => object

type ValidateDependency<
TService extends ServiceClass,
TContainedService

= ConstructorParameters<TService> extends TContainedService[]
? TService
: never

class Module<TContainedService = never> {
service<
...```

dense marlin
#

@proper solar ^

#

I don't know how you are supposed to implement the runtime code though.

proper solar
#

Probably pretty similar to how NestJs does it using reflect-metadata

#

@dense marlin is there a specific reason why you typed the class as

type ServiceClass = new (...args: never) => object

instead of

type ServiceClass<T> = new (...args: any[]) => T
dense marlin
#

any[] is not safe.

#

A specific return type is not needed in my solution hence I just used object, but if you need it then sure.

proper solar
austere jungleBOT
#
xilalus#0

Preview:```ts
type ServiceClass = new (...args: never) => object

type ValidateDependency<
TService extends ServiceClass,
TContainedService

= ConstructorParameters<TService> extends TContainedService[]
? TService
: never

class Module<TContainedService = never> {
service<
...```

proper solar
#

I quickly tried your solution but it does not detect that ServiceThree should also throw an error when ContactService is not provided

dense marlin
austere jungleBOT
#
nonspicyburrito#0

Preview:```ts
type ServiceClass = new (...args: never) => object

type ValidateDependency<
TService extends ServiceClass,
TContainedService

= ConstructorParameters<TService> extends TContainedService[]
? TService
: never

class Module<TContainedService = never> {
service<
...```

dense marlin
#

If you just make them structurally different, the error shows up as you expect.

proper solar
#

How tf was I not aware of that
I guess that happens when you learn a language from tutorials and stackoverflow answers xD

dense marlin
#

Tbf, TS for library code is very different from TS for application code.

#

The average TS developers use it only for application code, and these intricacies almost never matter.

proper solar
#

Yeah I only used other libraries so far and not created my own

#

This is a really different experience

#

Honestly my end goal for this is to learn ts more in depth and especially the more not so known parts of it

#

I was pleasantly surprised by how elysiajs is build and how it is able to create a typesafe fetch client just using type of the elysia app

dense marlin
#

Yeah you can do a lot with TS, type safe client/router/i18n is probably one of the best introduction to some practical TS magic.

#

What you are doing is a bit more advanced but still very approachable, although the solution I gave uses validator pattern which isn't something very well known outside of this server.

proper solar
dense marlin
#

The basic idea of validator pattern is this:

type Validate<T> = /* check something about T */ ? T : never

function fn<T>(arg: Validate<T>)

You need to ensure the Validate<T> type returns either T or a type that is incompatible with T (usually never) to trigger an error when the check fails.

#

So ValidateDependency<> is just using the validator pattern to check if all the dependencies are there.

proper solar
#

Can you link me any resources that list and explain these kind of patterns?

#

Don't really want to bother you more than necessary ^^

dense marlin
#

I don't think there's a comprehensive resource with all these advanced stuffs 🤔

#

You can always ask in this server though, people are pretty happy to discuss them.

proper solar
#

Awesome thank you again ^^

proper solar
#

@dense marlin I have a few more questions about my specific project and I hope it's alright if I ask you here.

I have made a little bit of progress on the dependency injection project. I would love some feedback from you regarding the typing of some methods and if you see some simple improvements I could make to make this more "professional". Below you can find the link to a playground where I setup some examples for the current state.

Question 1: The Generics of the Module class are a lot more complicated now and I am not sure if generally it is a good idea to have nested object types to keep track of for example the types of the service classes, their "factory" functions and which of them are supposed to be exported. Could you please have a look and let me know if and how I could/should refactor?

Question 2: How should I type a function/constructor that takes an instance created via the Module Builder as parameter, so that I still get good typing instead of having any everywhere in my code?

Question 3: In you example above you returned null as never from the class method service. I had to change it to this as never to make it work. Why never and not any? Should I never use any? In the import method of the Module class I have TModule extends Module<any, any, any> because otherwise it would not work.

austere jungleBOT
#
xilalus#0

Preview:```ts
type Prettify<T> = {
[K in keyof T]: T[K]
} & {}

type FactoryFunction<TContext, TResult> = (
$: TContext
) => TResult

type ModuleConfig<TName extends string> = {
name: TName
}

type ModuleServiceBase = {
classes: {}
factories: {}
exports: {
...```

dense marlin
proper solar
#

Okay thanks I'll have to check why I don't get ESLint warnings for that then

proper solar
#

It's awesome enough that you are down to help me out 😊

dense marlin
#

@proper solar Don't have much to say, there are small things here and there like the usages of any, { [_ in K]: V } is the same as Record<K, V> (but that's mostly a style choice)

#

The exportService parameter in Module#service feels a bit eh to me, I generally consider "function conditional return type depending on input type" an anti pattern.

#

Other than that, seems fine. The API design feels a bit too clunky though but I also don't use DI to comment on it really.

#

(I guess you are not doing the whole reflect metadata thing to construct services anymore, and instead have to manually write out the construction)

proper solar
dense marlin
#

Possibly unknown.

#

There's always a better type to use than any.

#

(Well almost, there's like one or two fringe cases where any has to be used, but they are fringe cases)

proper solar
proper solar
dense marlin
#

I guess it depends on if you want your API design to support a module containing multiples of the same service (which will also require a service to specify which one they want from the module)

#

If you don't need to support that you can have a much nicer design.

proper solar
proper solar
dense marlin
#

You can literally just have:

class SomeService {
    constructor(context: { RequiredService: RequiredService }) {}
}

And the module can simply store all the services in a context object, and calls constructors with that context object.

#

Required service check is also automatically done for you.

austere jungleBOT
#
nonspicyburrito#0

Preview:```ts
class Module<TContext = {}> {
service<
TName extends string,
TService extends new (context: TContext) => object

(
name: TName,
service: TService
): Module<
TContext & Record<TName, InstanceType<TService>>
{
return this as nev
...```

dense marlin
#

A minimal demo of the idea.

proper solar
#

Sadly there is no way to get the classes name as literal type right?

#

Because that would allow to just have the class as parameter for the service function

dense marlin
#

Yeah no way to do that, but well you could do something like:

new Module.service({ DatabaseService })
// instead of
new Module.service('DatabaseService', DatabaseService)
#

But service constructors still have to do it the verbose way.

proper solar
# dense marlin Yeah no way to do that, but well you could do something like: ```ts new Module.s...

I managed to create a validator type that allows all services to be added with only one call of .services(). I think I'll stick with this, thanks for the awesome idea :)

class ServiceOne {
  constructor() {}
  one() {}
}
class ServiceTwo {
  constructor(context: { ServiceOne: ServiceOne }) {}
  two() {}
}
class ServiceThree {
  constructor(context: { ServiceTwo: ServiceTwo }) {}
  three() {}
}
class ServiceFour {
  constructor(context: {
    ServiceOne: ServiceOne;
    ServiceTwo: ServiceTwo;
    ServiceThree: ServiceThree;
  }) {}
  four() {}
}

const AppModule = new Module().services({
  ServiceOne,
  ServiceTwo,
  ServiceThree,
  ServiceFour,
});

dense marlin
proper solar
austere jungleBOT
dense marlin
proper solar
#

Oh damn

#

When I use an array as input instead I would to put as const at the declaration to get the exact type right?

#

Maybe that could work

#

Because there the order should be the same right?