Skip to content

Commit

Permalink
Improved docs (#13)
Browse files Browse the repository at this point in the history
This PR adds docs for `InjectableClass` and `ServicesFromInjectables` types. Also adds test coverage for `ServicesFromInjectables`.
  • Loading branch information
mikalai-snap authored Nov 20, 2024
1 parent bb78e35 commit 44b4588
Show file tree
Hide file tree
Showing 4 changed files with 148 additions and 6 deletions.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@snap/ts-inject",
"version": "0.3.0",
"version": "0.3.1",
"description": "100% typesafe dependency injection framework for TypeScript projects",
"license": "MIT",
"author": "Snap Inc.",
Expand Down
99 changes: 99 additions & 0 deletions src/__tests__/types.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof injectables>;

// 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<Services>;

// 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<typeof injectables>;

let container: Container<Services>;

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<typeof injectables>;

// 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();
});
});
49 changes: 46 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,14 @@ export type CorrespondingServices<Services, Tokens extends readonly ValidTokens<
};

/**
* A valid InjectableFunction is one that can be successfully called, given some Services, to return a new Service. That
* is, it must satisfy two conditions:
* A valid `InjectableFunction` is one that can be successfully called, given some Services, to return a new Service.
* That is, it must satisfy two conditions:
*
* 1. All the Tokens it specifies as dependencies are valid given the Services (i.e. they are either the Container
* Token or keys of the Services type).
* 2. The function argument types correspond to the Services specified by the dependency Tokens.
*
* A InjectableFunction also includes its own key Token and dependency Tokens as metadata, so it may be resolved by
* A `InjectableFunction` also includes its own key Token and dependency Tokens as metadata, so it may be resolved by
* Container<Services> later.
*/
export type InjectableFunction<
Expand All @@ -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<Services, Service, Tokens> = Tokens extends readonly ValidTokens<Services>[]
? {
readonly dependencies: Tokens;
Expand All @@ -58,6 +63,44 @@ export type InjectableClass<Services, Service, Tokens> = Tokens extends readonly

export type AnyInjectable = InjectableFunction<any, readonly TokenType[], TokenType, any>;

/**
* 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<typeof injectables>;
*
* // 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<Services>;
*
* // Assign the container with the actual instance
* container = Container.provides(injectable1).provides(injectable2);
*/
export type ServicesFromInjectables<Injectables extends readonly AnyInjectable[]> = {
[Name in Injectables[number]["token"]]: ReturnType<Extract<Injectables[number], { token: Name }>>;
};
Expand Down

0 comments on commit 44b4588

Please sign in to comment.