Skip to content

Commit d716d2a

Browse files
authored
Partial container API improvements (#6)
- Added `providesValue` and `providesClass` to the `PartialContainer` API - Extracted common logic for initialising class-based injectables
1 parent 3346f61 commit d716d2a

7 files changed

+222
-63
lines changed

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@snap/ts-inject",
3-
"version": "0.1.2",
3+
"version": "0.2.0",
44
"description": "100% typesafe dependency injection framework for TypeScript projects",
55
"license": "MIT",
66
"author": "Snap Inc.",

src/Container.ts

+21-52
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { isMemoized, memoize } from "./memoize";
21
import type { Memoized } from "./memoize";
2+
import { isMemoized, memoize } from "./memoize";
33
import { PartialContainer } from "./PartialContainer";
44
import type { AddService, AddServices, InjectableClass, InjectableFunction, TokenType, ValidTokens } from "./types";
5-
import { ConcatInjectable } from "./Injectable";
5+
import { ClassInjectable, ConcatInjectable, Injectable } from "./Injectable";
66
import { entries } from "./entries";
77

88
type MaybeMemoizedFactories<Services> = {
@@ -407,13 +407,6 @@ export class Container<Services = {}> {
407407
return this.providesService(fnOrContainer);
408408
}
409409

410-
/**
411-
* Create a new Container which provides a Service created by the given [InjectableClass].
412-
*
413-
* @param token - A unique Token which will correspond to the created Service.
414-
* @param cls - A class with a constructor that takes dependencies as arguments, which returns the Service.
415-
*/
416-
417410
/**
418411
* Registers a service in the container using a class constructor, simplifying the service creation process.
419412
*
@@ -425,22 +418,10 @@ export class Container<Services = {}> {
425418
* specifying these dependencies.
426419
* @returns A new Container instance containing the newly created service, allowing for method chaining.
427420
*/
428-
providesClass<Token extends TokenType, Service, Tokens extends readonly ValidTokens<Services>[]>(
421+
providesClass = <Token extends TokenType, Service, Tokens extends readonly ValidTokens<Services>[]>(
429422
token: Token,
430423
cls: InjectableClass<Services, Service, Tokens>
431-
): Container<AddService<Services, Token, Service>> {
432-
const dependencies: readonly any[] = cls.dependencies;
433-
// If the service depends on itself, e.g. in the multi-binding case, where we call append multiple times with
434-
// the same token, we always must resolve the dependency using the parent container to avoid infinite loop.
435-
const getFromParent = dependencies.indexOf(token) !== -1 ? () => this.get(token as any) : undefined;
436-
const factory = memoize(this, function (this: Container<Services>) {
437-
// Safety: getFromParent is defined if the token is in the dependencies list, so it is safe to call it.
438-
return new cls(...(dependencies.map((t) => (t === token ? getFromParent!() : this.get(t))) as any));
439-
});
440-
441-
const factories = { ...this.factories, [token]: factory };
442-
return new Container(factories as unknown as MaybeMemoizedFactories<AddService<Services, Token, Service>>);
443-
}
424+
) => this.providesService(ClassInjectable(token, cls));
444425

445426
/**
446427
* Registers a static value as a service in the container. This method is ideal for services that do not
@@ -452,14 +433,8 @@ export class Container<Services = {}> {
452433
* @returns A new Container instance that includes the provided service, allowing for chaining additional
453434
* `provides` calls.
454435
*/
455-
providesValue<Token extends TokenType, Service>(
456-
token: Token,
457-
value: Service
458-
): Container<AddService<Services, Token, Service>> {
459-
const factory = memoize(this, () => value);
460-
const factories = { ...this.factories, [token]: factory };
461-
return new Container(factories as unknown as MaybeMemoizedFactories<AddService<Services, Token, Service>>);
462-
}
436+
providesValue = <Token extends TokenType, Service>(token: Token, value: Service) =>
437+
this.providesService(Injectable(token, [], () => value));
463438

464439
/**
465440
* Appends a value to the array associated with a specified token in the current Container, then returns
@@ -477,14 +452,10 @@ export class Container<Services = {}> {
477452
* @param value - A value to append to the array.
478453
* @returns The updated Container with the appended value in the specified array.
479454
*/
480-
appendValue<Token extends keyof Services, Service extends ArrayElement<Services[Token]>>(
455+
appendValue = <Token extends keyof Services, Service extends ArrayElement<Services[Token]>>(
481456
token: Token,
482457
value: Service
483-
): Service extends any ? Container<Services> : never;
484-
485-
appendValue<Token extends TokenType, Service>(token: Token, value: Service): Container<any> {
486-
return this.providesService(ConcatInjectable(token, () => value));
487-
}
458+
) => this.providesService(ConcatInjectable(token, () => value)) as Container<Services>;
488459

489460
/**
490461
* Appends an injectable class factory to the array associated with a specified token in the current Container,
@@ -501,18 +472,17 @@ export class Container<Services = {}> {
501472
* @param cls - A class with a constructor that takes dependencies as arguments, which returns the Service.
502473
* @returns The updated Container with the new service instance appended to the specified array.
503474
*/
504-
appendClass<
475+
appendClass = <
505476
Token extends keyof Services,
506477
Tokens extends readonly ValidTokens<Services>[],
507478
Service extends ArrayElement<Services[Token]>,
508-
>(token: Token, cls: InjectableClass<Services, Service, Tokens>): Service extends any ? Container<Services> : never;
509-
510-
appendClass<Token extends TokenType, Tokens extends readonly ValidTokens<Services>[], Service>(
479+
>(
511480
token: Token,
512481
cls: InjectableClass<Services, Service, Tokens>
513-
): Container<any> {
514-
return this.providesService(ConcatInjectable(token, () => this.providesClass(token, cls).get(token)));
515-
}
482+
) =>
483+
this.providesService(
484+
ConcatInjectable(token, () => this.providesClass(token, cls).get(token))
485+
) as Container<Services>;
516486

517487
/**
518488
* Appends a new service instance to an existing array within the container using an `InjectableFunction`.
@@ -531,17 +501,16 @@ export class Container<Services = {}> {
531501
* @returns The updated Container, now including the new service instance appended to the array
532502
* specified by the token.
533503
*/
534-
append<
504+
append = <
535505
Token extends keyof Services,
536506
Tokens extends readonly ValidTokens<Services>[],
537507
Service extends ArrayElement<Services[Token]>,
538-
>(fn: InjectableFunction<Services, Tokens, Token, Service>): Service extends any ? Container<Services> : never;
539-
540-
append<Token extends TokenType, Tokens extends readonly ValidTokens<Services>[], Service>(
508+
>(
541509
fn: InjectableFunction<Services, Tokens, Token, Service>
542-
): Container<any> {
543-
return this.providesService(ConcatInjectable(fn.token, () => this.providesService(fn).get(fn.token)));
544-
}
510+
) =>
511+
this.providesService(
512+
ConcatInjectable(fn.token, () => this.providesService(fn).get(fn.token))
513+
) as Container<Services>;
545514

546515
private providesService<Token extends TokenType, Tokens extends readonly ValidTokens<Services>[], Service>(
547516
fn: InjectableFunction<Services, Tokens, Token, Service>
@@ -559,6 +528,6 @@ export class Container<Services = {}> {
559528
// MaybeMemoizedFactories object with the expected set of services – but when using the spread operation to
560529
// merge two objects, the compiler widens the Token type to string. So we must re-narrow via casting.
561530
const factories = { ...this.factories, [token]: factory };
562-
return new Container(factories as unknown as MaybeMemoizedFactories<AddService<Services, Token, Service>>);
531+
return new Container(factories) as Container<AddService<Services, Token, Service>>;
563532
}
564533
}

src/Injectable.ts

+44-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { InjectableFunction, ServicesFromTokenizedParams, TokenType } from "./types";
1+
import type { InjectableClass, InjectableFunction, ServicesFromTokenizedParams, TokenType } from "./types";
22

33
/**
44
* Creates an Injectable factory function designed for services without dependencies.
@@ -100,6 +100,49 @@ export function Injectable(
100100
return factory;
101101
}
102102

103+
/**
104+
* Creates an Injectable factory function for an InjectableClass.
105+
*
106+
* @example
107+
* ```ts
108+
* class InjectableClassService {
109+
* static dependencies = ["service"] as const;
110+
* constructor(public service: string) {}
111+
* public print(): string {
112+
* console.log(this.service);
113+
* }
114+
* }
115+
*
116+
* let container = Container.provides("service", "service value")
117+
* .provides(ClassInjectable("classService", InjectableClassService));
118+
*
119+
* container.get("classService").print(); // prints "service value"
120+
*
121+
* // prefer using Container's provideClass method. Above is the equivalent of:
122+
* container = Container.provides("service", "service value")
123+
* .providesClass("classService", InjectableClassService);
124+
*
125+
* container.get("classService").print(); // prints "service value"
126+
* ```
127+
*
128+
* @param token Token identifying the Service.
129+
* @param cls InjectableClass to instantiate.
130+
*/
131+
export function ClassInjectable<Services, Token extends TokenType, const Tokens extends readonly TokenType[], Service>(
132+
token: Token,
133+
cls: InjectableClass<Services, Service, Tokens>
134+
): InjectableFunction<Services, Tokens, Token, Service>;
135+
136+
export function ClassInjectable(
137+
token: TokenType,
138+
cls: InjectableClass<any, any, readonly TokenType[]>
139+
): InjectableFunction<any, readonly TokenType[], TokenType, any> {
140+
const factory = (...args: any[]) => new cls(...args);
141+
factory.token = token;
142+
factory.dependencies = cls.dependencies;
143+
return factory;
144+
}
145+
103146
/**
104147
* Creates an Injectable factory function without dependencies that appends a Service
105148
* to an existing array of Services of the same type. Useful for dynamically expanding

src/PartialContainer.ts

+58-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,17 @@ import { entries } from "./entries";
22
import { memoize } from "./memoize";
33
import type { Memoized } from "./memoize";
44
import type { Container } from "./Container";
5-
import type { AddService, InjectableFunction, ServicesFromTokenizedParams, TokenType, ValidTokens } from "./types";
5+
import type {
6+
AddService,
7+
InjectableClass,
8+
InjectableFunction,
9+
ServicesFromTokenizedParams,
10+
TokenType,
11+
ValidTokens,
12+
} from "./types";
13+
import { ClassInjectable, Injectable } from "./Injectable";
14+
15+
type ConstructorReturnType<T> = T extends new (...args: any) => infer C ? C : any;
616

717
// Using a conditional type forces TS language services to evaluate the type -- so when showing e.g. type hints, we
818
// will see the mapped type instead of the AddDependencies type alias. This produces better hints.
@@ -77,7 +87,7 @@ export class PartialContainer<Services = {}, Dependencies = {}> {
7787
* The InjectableFunction contains metadata specifying the Token by which the created Service will be known, as well
7888
* as an ordered list of Tokens to be resolved and provided to the InjectableFunction as arguments.
7989
*
80-
* This dependencies are allowed to be missing from the PartialContainer, but these dependencies are maintained as a
90+
* The dependencies are allowed to be missing from the PartialContainer, but these dependencies are maintained as a
8191
* parameter of the returned PartialContainer. This allows `[Container.provides]` to type check the dependencies and
8292
* ensure they can be provided by the Container.
8393
*
@@ -103,6 +113,52 @@ export class PartialContainer<Services = {}, Dependencies = {}> {
103113
return new PartialContainer({ ...this.injectables, [fn.token]: fn } as any);
104114
}
105115

116+
/**
117+
* Create a new PartialContainer which provides the given value as a Service.
118+
*
119+
* Example:
120+
* ```ts
121+
* const partial = new PartialContainer({}).providesValue("value", 42);
122+
* const value = Container.provides(partial).get("value");
123+
* console.log(value); // 42
124+
* ```
125+
*
126+
* @param token the Token by which the value will be known.
127+
* @param value the value to be provided.
128+
*/
129+
providesValue = <Token extends TokenType, Service>(token: Token, value: Service) =>
130+
this.provides(Injectable(token, [], () => value));
131+
132+
/**
133+
* Create a new PartialContainer which provides the given class as a Service, all of the class's dependencies will be
134+
* resolved by the parent Container.
135+
*
136+
* Example:
137+
* ```ts
138+
* class Foo {
139+
* static dependencies = ['bar'] as const;
140+
* constructor(public bar: string) {}
141+
* }
142+
*
143+
* const partial = new PartialContainer({}).providesClass("foo", Foo);
144+
* const foo = Container.providesValue("bar", "bar value").provides(partial).get("foo");
145+
* console.log(foo.bar); // "bar value"
146+
* ```
147+
*
148+
* @param token the Token by which the class will be known.
149+
* @param cls the class to be provided, must match the InjectableClass type.
150+
*/
151+
providesClass = <
152+
Class extends InjectableClass<any, any, any>,
153+
AdditionalDependencies extends ConstructorParameters<Class>,
154+
Tokens extends Class["dependencies"],
155+
Service extends ConstructorReturnType<Class>,
156+
Token extends TokenType,
157+
>(
158+
token: Token,
159+
cls: Class
160+
) => this.provides<AdditionalDependencies, Tokens, Token, Service>(ClassInjectable(token, cls));
161+
106162
/**
107163
* In order to create a [Container], the InjectableFunctions maintained by the PartialContainer must be memoized
108164
* into Factories that can resolve their dependencies and return the correct Service.

src/__tests__/Container.spec.ts

+48
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// eslint-disable-next-line max-classes-per-file
12
import { Container } from "../Container";
23
import { Injectable } from "../Injectable";
34
import { PartialContainer } from "../PartialContainer";
@@ -141,6 +142,53 @@ describe("Container", () => {
141142
});
142143
});
143144

145+
describe("when providing a Service using providesClass", () => {
146+
const container = Container.providesValue("value", 1);
147+
148+
test("test simple case", () => {
149+
class Item {
150+
static dependencies = ["value"] as const;
151+
constructor(public value: number) {}
152+
}
153+
const containerWithService = container.providesClass("service", Item);
154+
expect(containerWithService.get("service")).toEqual(new Item(1));
155+
});
156+
157+
test("error if class constructor arity doesn't match dependencies", () => {
158+
class Item {
159+
static dependencies = ["value", "value2"] as const;
160+
constructor(public value: number) {}
161+
}
162+
// @ts-expect-error should be failing to compile as the constructor doesn't match dependencies
163+
expect(() => container.providesClass("service", Item).get("service")).toThrow();
164+
// should not fail now as we provide the missing dependency
165+
container.providesValue("value2", 2).providesClass("service", Item).get("service");
166+
});
167+
168+
test("error if class constructor argument type doesn't match provided by container", () => {
169+
class Item {
170+
static dependencies = ["value"] as const;
171+
constructor(public value: string) {}
172+
}
173+
// @ts-expect-error must fail to compile as the constructor argument type doesn't match dependencies
174+
container.providesClass("service", Item).get("service");
175+
// should not fail now as we provide the correct type
176+
container.providesValue("value", "1").providesClass("service", Item).get("service");
177+
});
178+
179+
test("error if class constructor argument type doesn't match provided by container", () => {
180+
class Item {
181+
static dependencies = ["value"] as const;
182+
constructor(
183+
public value: number,
184+
public value2: string
185+
) {}
186+
}
187+
// @ts-expect-error must fail to compile as the constructor arity type doesn't match dependencies array length
188+
container.providesValue("value2", "2").providesClass("service", Item).get("service");
189+
});
190+
});
191+
144192
describe("when providing a PartialContainer", () => {
145193
let service1: InjectableFunction<any, [], "Service1", string>;
146194
let service2: InjectableFunction<any, [], "Service2", number>;

0 commit comments

Comments
 (0)