diff --git a/src/__tests__/types.spec.ts b/src/__tests__/types.spec.ts new file mode 100644 index 0000000..6751931 --- /dev/null +++ b/src/__tests__/types.spec.ts @@ -0,0 +1,99 @@ +import { Injectable } from "../Injectable"; +import type { ServicesFromInjectables } from "../types"; +import { Container } from "../Container"; + +describe("ServicesFromInjectables", () => { + test("correctly maps injectables to service types and allow container type definition before construction.", () => { + const injectable1 = Injectable("Service1", () => "service1"); + const injectable2 = Injectable("Service2", () => 42); + + const injectables = [injectable1, injectable2] as const; + + // Use ServicesFromInjectables to derive the services' types + type Services = ServicesFromInjectables; + + // Services type is equivalent to: + // { + // Service1: string; + // Service2: number; + // } + + // Declare a container variable with the derived Services type + // This allows us to reference the container with accurate typing before it's constructed, + // ensuring type safety and enabling its use in type annotations elsewhere + let container: Container; + + // Assign the container with the actual instance + container = Container.provides(injectable1).provides(injectable2); + + // Retrieve services with accurate typing + const service1 = container.get("Service1"); // Type: string + const service2 = container.get("Service2"); // Type: number + + // @ts-expect-error + expect(() => container.get("NonExistentService")).toThrow(); + + // @ts-expect-error + const invalidService1: number = container.get("Service1"); + // @ts-expect-error + const invalidService2: string = container.get("Service2"); + + // Use the services + expect(service1).toBe("service1"); + expect(service2).toBe(42); + }); + + test("handles injectables with dependencies and allow pre-definition of container type", () => { + const injectableDep = Injectable("DepService", () => 100); + const injectableMain = Injectable("MainService", ["DepService"] as const, (dep: number) => dep + 1); + + const injectables = [injectableDep, injectableMain] as const; + + type Services = ServicesFromInjectables; + + let container: Container; + + container = Container.provides(injectableDep).provides(injectableMain); + + expect(container.get("DepService")).toBe(100); + expect(container.get("MainService")).toBe(101); + }); + + test("enforces type safety when assigning services.", () => { + const injectable1 = Injectable("Service1", () => "service1"); + const injectable2 = Injectable("Service2", () => 42); + + const injectables = [injectable1, injectable2] as const; + + type Services = ServicesFromInjectables; + + // Correct assignment + const services: Services = { + Service1: "service1", + Service2: 42, + }; + + // Attempting incorrect assignments should result in TypeScript errors + + const invalidServices1: Services = { + Service1: "service1", + // @ts-expect-error + Service2: "not a number", // Error: Type 'string' is not assignable to type 'number' + }; + + const invalidServices2: Services = { + // @ts-expect-error + Service1: 123, // Error: Type 'number' is not assignable to type 'string' + Service2: 42, + }; + + // @ts-expect-error + const invalidServices3: Services = { + Service1: "service1", + // Missing 'Service2' property + }; + + // avoid the "unused variable" TypeScript error + expect(services ?? invalidServices1 ?? invalidServices2 ?? invalidServices3).toBeDefined(); + }); +}); diff --git a/src/types.ts b/src/types.ts index a7b3288..d29a561 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,14 +26,14 @@ export type CorrespondingServices later. */ export type InjectableFunction< @@ -49,6 +49,11 @@ export type InjectableFunction< } : never; +/** + * Represents a class that can be used as an injectable service within a dependency injection {@link Container}. + * The `InjectableClass` type ensures that the class's dependencies and constructor signature align with + * the services available in the container, providing strong type safety. + */ export type InjectableClass = Tokens extends readonly ValidTokens[] ? { readonly dependencies: Tokens; @@ -58,6 +63,44 @@ export type InjectableClass = Tokens extends readonly export type AnyInjectable = InjectableFunction; +/** + * Maps an array of {@link InjectableFunction} to a service type object, where each key is the token of an + * {@link Injectable}, and the corresponding value is the return type of that {@link Injectable}. + * + * This utility type is useful for deriving the service types provided by a collection of {@link InjectableFunction}s, + * ensuring type safety and consistency throughout your application. + * + * You can use `ServicesFromInjectables` to construct a type that serves as a type parameter for a {@link Container}, + * allowing the container's type to accurately reflect the services it provides, + * even before the container is constructed. + * + * @typeParam Injectables - A tuple of {@link InjectableFunction}s. + * + * @example + * // Define some Injectable functions + * const injectable1 = Injectable("Service1", () => "service1"); + * const injectable2 = Injectable("Service2", () => 42); + * + * // Collect them in a tuple + * const injectables = [injectable1, injectable2] as const; + * + * // Use ServicesFromInjectables to derive the services' types + * type Services = ServicesFromInjectables; + * + * // Services type is equivalent to: + * // { + * // Service1: string; + * // Service2: number; + * // } + * + * // Declare a container variable with the derived Services type + * // This allows us to reference the container with accurate typing before it's constructed, + * // ensuring type safety and enabling its use in type annotations elsewhere + * let container: Container; + * + * // Assign the container with the actual instance + * container = Container.provides(injectable1).provides(injectable2); + */ export type ServicesFromInjectables = { [Name in Injectables[number]["token"]]: ReturnType>; };