Skip to content

Commit 811d992

Browse files
authored
feat: add type validation to bindings (#7)
This adds type validation to all binding methods on a `TypedContainer` or `TypedModule`, including `toClass`, `toHigherOrderFunction` and `toCurry`, allowing TypeScript to catch common errors when defining bindings, such as omitting or mixing up dependency keys.
1 parent a146049 commit 811d992

4 files changed

Lines changed: 267 additions & 35 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ Then you can bind the dependency to a value, a function, a class, a factory, a h
3636

3737
You can define a registry type or interface to get full type safety for your dependency injection keys and their corresponding types. This eliminates the need for manual casting when resolving dependencies.
3838

39+
The container also validates that the dependencies you provide match what your classes or functions expect. TypeScript will catch errors at compile time such as omitting required dependencies or providing the wrong dependency types.
40+
3941
### Create the Registry
4042

4143
Define a type or interface that maps your injection tokens to the desired types:

specs/examples/Registry.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export type TestRegistry = {
99
'MY_USE_CASE': MyUseCase;
1010
'CLASS_WITH_DEPENDENCIES': ServiceClass;
1111
'CLASS_WITHOUT_DEPENDENCIES': ServiceClass;
12+
'MY_CURRIED_FUNCTION': (name: string) => string;
1213
}
1314

1415
export interface UserService {

specs/registry.spec.ts

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {createContainer, TypedContainer} from '../src';
1+
import {createContainer, createModule, TypedContainer, type TypedModule} from '../src';
22
import {
33
FakeLogger,
44
ServiceClass,
@@ -8,6 +8,11 @@ import {
88
simpleFunction,
99
TestRegistry
1010
} from "./examples/Registry";
11+
import {
12+
HigherOrderFunctionWithDependencies,
13+
HigherOrderFunctionWithDependencyObject
14+
} from "./examples/HigherOrderFunctions";
15+
import { curriedFunctionWithDependencies } from "./examples/Currying";
1116

1217
describe('Registry', () => {
1318
let container: TypedContainer<TestRegistry>;
@@ -216,6 +221,153 @@ describe('Registry', () => {
216221
dep2: UNKNOWN_DEP
217222
});
218223
});
224+
225+
it('should not allow wrong dependency types for array deps', () => {
226+
// Arrange
227+
const symbolContainer = createContainer<SymbolRegistry>();
228+
symbolContainer.bind(DEP1).toValue('dep1');
229+
symbolContainer.bind(DEP2).toValue(1);
230+
231+
// Act & Assert
232+
// @ts-expect-error - DEP2 is number but dep1 expects string
233+
symbolContainer.bind(CLASS_WITH_DEPENDENCIES).toClass(ServiceClassWithDeps, [DEP2, DEP1]);
234+
});
235+
236+
it('should not allow wrong dependency types for object deps', () => {
237+
// Arrange
238+
const symbolContainer = createContainer<SymbolRegistry>();
239+
symbolContainer.bind(DEP1).toValue('dep1');
240+
symbolContainer.bind(DEP2).toValue(1);
241+
242+
// Act & Assert
243+
// @ts-expect-error - DEP2 is number but dep1 expects string
244+
symbolContainer.bind(CLASS_WITH_DEPENDENCIES).toClass(ServiceClassWithObjectDeps, {
245+
dep1: DEP2,
246+
dep2: DEP1
247+
});
248+
});
249+
});
250+
251+
describe('When using string keys', () => {
252+
it('should not allow wrong dependency types for array deps', () => {
253+
// Arrange
254+
container.bind('DEP1').toValue('dep1');
255+
container.bind('DEP2').toValue(1);
256+
257+
// Act & Assert
258+
// @ts-expect-error - DEP2 is number but dep1 expects string
259+
container.bind('CLASS_WITH_DEPENDENCIES').toClass(ServiceClassWithDeps, ['DEP2', 'DEP1']);
260+
});
261+
262+
it('should not allow wrong dependency types for object deps', () => {
263+
// Arrange
264+
container.bind('DEP1').toValue('dep1');
265+
container.bind('DEP2').toValue(1);
266+
267+
// Act & Assert
268+
// @ts-expect-error - DEP2 is number but dep1 expects string
269+
container.bind('CLASS_WITH_DEPENDENCIES').toClass(ServiceClassWithObjectDeps, {
270+
dep1: 'DEP2',
271+
dep2: 'DEP1'
272+
});
273+
});
274+
275+
describe('toHigherOrderFunction()', () => {
276+
it('should not allow wrong dependency types for array deps', () => {
277+
// Arrange
278+
container.bind('DEP1').toValue('dep1');
279+
container.bind('DEP2').toValue(1);
280+
281+
// Act & Assert
282+
// @ts-expect-error - DEP2 is number but dep1 expects string
283+
container.bind('MY_SERVICE').toHigherOrderFunction(HigherOrderFunctionWithDependencies, ['DEP2', 'DEP1']);
284+
});
285+
286+
it('should require dependencies when function requires them', () => {
287+
// Act & Assert
288+
// @ts-expect-error - omitting required dependencies
289+
container.bind('MY_SERVICE').toHigherOrderFunction(HigherOrderFunctionWithDependencies);
290+
});
291+
});
292+
293+
describe('toCurry()', () => {
294+
it('should not allow wrong dependency types for array deps', () => {
295+
// Arrange
296+
container.bind('DEP2').toValue(1);
297+
298+
// Act & Assert
299+
// @ts-expect-error - DEP2 is number but dep1 expects string
300+
container.bind('MY_CURRIED_FUNCTION').toCurry(curriedFunctionWithDependencies, ['DEP2']);
301+
});
302+
303+
it('should require dependencies when function requires them', () => {
304+
// Act & Assert
305+
// @ts-expect-error - omitting required dependencies
306+
container.bind('MY_CURRIED_FUNCTION').toCurry(curriedFunctionWithDependencies);
307+
});
308+
});
309+
310+
describe('toClass() with conditionally required dependencies', () => {
311+
it('should require dependencies when constructor requires them', () => {
312+
// Act & Assert
313+
// @ts-expect-error - omitting required dependencies
314+
container.bind('CLASS_WITH_DEPENDENCIES').toClass(ServiceClassWithDeps);
315+
});
316+
317+
it('should allow omitting dependencies when constructor needs none', () => {
318+
// Act & Assert - should not error
319+
container.bind('CLASS_WITHOUT_DEPENDENCIES').toClass(ServiceClassNoDeps);
320+
});
321+
});
322+
});
323+
324+
describe('When using a typed module', () => {
325+
it('should not allow missing dependencies in toClass()', () => {
326+
// Arrange
327+
const mod = createModule<TestRegistry>();
328+
329+
// Act & Assert
330+
// @ts-expect-error - empty object doesn't satisfy the required deps
331+
mod.bind('CLASS_WITH_DEPENDENCIES').toClass(ServiceClassWithDeps, {});
332+
});
333+
334+
it('should not allow wrong dependency types for array deps in toClass()', () => {
335+
// Arrange
336+
const mod = createModule<TestRegistry>();
337+
338+
// Act & Assert
339+
// @ts-expect-error - DEP2 is number but dep1 expects string
340+
mod.bind('CLASS_WITH_DEPENDENCIES').toClass(ServiceClassWithDeps, ['DEP2', 'DEP1']);
341+
});
342+
343+
it('should not allow wrong dependency types for object deps in toClass()', () => {
344+
// Arrange
345+
const mod = createModule<TestRegistry>();
346+
347+
// Act & Assert
348+
// @ts-expect-error - DEP2 is number but dep1 expects string
349+
mod.bind('CLASS_WITH_DEPENDENCIES').toClass(ServiceClassWithObjectDeps, {
350+
dep1: 'DEP2',
351+
dep2: 'DEP1'
352+
});
353+
});
354+
355+
it('should require dependencies when constructor requires them', () => {
356+
// Arrange
357+
const mod = createModule<TestRegistry>();
358+
359+
// Act & Assert
360+
// @ts-expect-error - omitting required dependencies
361+
mod.bind('CLASS_WITH_DEPENDENCIES').toClass(ServiceClassWithDeps);
362+
});
363+
364+
it('should allow omitting dependencies when constructor needs none', () => {
365+
// Arrange
366+
const mod = createModule<TestRegistry>();
367+
368+
// Act & Assert - should not error
369+
mod.bind('CLASS_WITHOUT_DEPENDENCIES').toClass(ServiceClassNoDeps);
370+
});
219371
});
220372
});
221373
});

src/types.ts

Lines changed: 111 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,48 @@ export interface DefaultRegistry {
1616

1717
type RegistryKey<TRegistry> = Extract<keyof TRegistry, DependencyKey>;
1818

19-
type RegistryDependencyArray<TRegistry> = readonly RegistryKey<TRegistry>[];
19+
// Find all registry keys whose resolved type extends T
20+
type KeysMatching<TRegistry, T> = {
21+
[K in RegistryKey<TRegistry>]: TRegistry[K & keyof TRegistry] extends T
22+
? K
23+
: never;
24+
}[RegistryKey<TRegistry>];
25+
26+
// For each position in a params tuple, compute the valid registry keys
27+
type ValidatedArrayDeps<TRegistry, TParams extends readonly unknown[]> = {
28+
readonly [I in keyof TParams]: KeysMatching<TRegistry, TParams[I]>;
29+
};
2030

21-
type RegistryDependencyObject<TRegistry> = {
22-
[key: string]: RegistryKey<TRegistry>;
31+
// For a single object parameter, map each property to valid registry keys
32+
type ValidatedObjectDeps<TRegistry, TParam> = {
33+
[P in keyof TParam]: KeysMatching<TRegistry, TParam[P]>;
2334
};
2435

25-
type RegistryDependencies<TRegistry> = RegistryDependencyArray<TRegistry> | RegistryDependencyObject<TRegistry>;
36+
// Shared core, validate deps against a parameter tuple
37+
type ValidDepsForParams<
38+
TRegistry,
39+
TParams extends readonly unknown[]
40+
> = TParams extends readonly []
41+
? never
42+
: TParams extends readonly [infer Only, ...infer Rest]
43+
? Rest extends []
44+
? Only extends Record<string, unknown>
45+
? ValidatedArrayDeps<TRegistry, [Only]> | ValidatedObjectDeps<TRegistry, Only>
46+
: ValidatedArrayDeps<TRegistry, [Only]>
47+
: ValidatedArrayDeps<TRegistry, TParams>
48+
: never;
49+
50+
// Combine valid dependencies for a given constructor
51+
type ValidDepsFor<
52+
TRegistry,
53+
TClass extends new (...args: any[]) => any
54+
> = ValidDepsForParams<TRegistry, ConstructorParameters<TClass>>;
55+
56+
// Combine valid dependencies for a given function
57+
type ValidFnDepsFor<
58+
TRegistry,
59+
TFn extends (...args: any[]) => any
60+
> = ValidDepsForParams<TRegistry, Parameters<TFn>>;
2661

2762
type IncompatibleOverride<K extends PropertyKey, Expected, Provided> = {
2863
__error: 'Incompatible override type for registry key';
@@ -67,22 +102,43 @@ interface TypedBindable<TRegistry> {
67102
bind<K extends RegistryKey<TRegistry>>(key: K): {
68103
toValue: (value: TRegistry[K]) => void;
69104
toFunction: (fn: CallableFunction) => void;
70-
toHigherOrderFunction: (
71-
fn: CallableFunction,
72-
dependencies?: RegistryDependencies<TRegistry>,
73-
scope?: Scope
74-
) => void;
75-
toCurry: (
76-
fn: CallableFunction,
77-
dependencies?: RegistryDependencies<TRegistry>,
78-
scope?: Scope
79-
) => void;
105+
toHigherOrderFunction: {
106+
<TFn extends (...args: readonly []) => TRegistry[K]>(
107+
fn: TFn,
108+
dependencies?: undefined,
109+
scope?: Scope
110+
): void;
111+
<TFn extends (...args: any[]) => TRegistry[K]>(
112+
fn: TFn,
113+
dependencies: ValidFnDepsFor<TRegistry, TFn>,
114+
scope?: Scope
115+
): void;
116+
};
117+
toCurry: {
118+
<TFn extends (...args: readonly []) => TRegistry[K]>(
119+
fn: TFn,
120+
dependencies?: undefined,
121+
scope?: Scope
122+
): void;
123+
<TFn extends (...args: any[]) => TRegistry[K]>(
124+
fn: TFn,
125+
dependencies: ValidFnDepsFor<TRegistry, TFn>,
126+
scope?: Scope
127+
): void;
128+
};
80129
toFactory: (factory: CallableFunction, scope?: Scope) => void;
81-
toClass: <C>(
82-
constructor: new (...args: any[]) => C,
83-
dependencies?: RegistryDependencies<TRegistry>,
84-
scope?: Scope
85-
) => void;
130+
toClass: {
131+
<TClass extends new () => TRegistry[K]>(
132+
constructor: TClass,
133+
dependencies?: undefined,
134+
scope?: Scope
135+
): void;
136+
<TClass extends new (...args: any[]) => TRegistry[K]>(
137+
constructor: TClass,
138+
dependencies: ValidDepsFor<TRegistry, TClass>,
139+
scope?: Scope
140+
): void;
141+
};
86142
};
87143

88144
bind(key: DependencyKey): {
@@ -121,22 +177,43 @@ export interface TypedContainer<TRegistry> {
121177
bind<K extends RegistryKey<TRegistry>>(key: K): {
122178
toValue: (value: TRegistry[K]) => void;
123179
toFunction: (fn: TRegistry[K] extends CallableFunction ? TRegistry[K] : never) => void;
124-
toHigherOrderFunction: (
125-
fn: CallableFunction,
126-
dependencies?: RegistryDependencies<TRegistry>,
127-
scope?: Scope
128-
) => void;
129-
toCurry: (
130-
fn: CallableFunction,
131-
dependencies?: RegistryDependencies<TRegistry>,
132-
scope?: Scope
133-
) => void;
180+
toHigherOrderFunction: {
181+
<TFn extends (...args: readonly []) => TRegistry[K]>(
182+
fn: TFn,
183+
dependencies?: undefined,
184+
scope?: Scope
185+
): void;
186+
<TFn extends (...args: any[]) => TRegistry[K]>(
187+
fn: TFn,
188+
dependencies: ValidFnDepsFor<TRegistry, TFn>,
189+
scope?: Scope
190+
): void;
191+
};
192+
toCurry: {
193+
<TFn extends (...args: readonly []) => TRegistry[K]>(
194+
fn: TFn,
195+
dependencies?: undefined,
196+
scope?: Scope
197+
): void;
198+
<TFn extends (...args: any[]) => TRegistry[K]>(
199+
fn: TFn,
200+
dependencies: ValidFnDepsFor<TRegistry, TFn>,
201+
scope?: Scope
202+
): void;
203+
};
134204
toFactory: (factory: (resolve: (key: DependencyKey) => unknown) => TRegistry[K], scope?: Scope) => void;
135-
toClass: (
136-
constructor: new (...args: any[]) => TRegistry[K],
137-
dependencies?: RegistryDependencies<TRegistry>,
138-
scope?: Scope
139-
) => void;
205+
toClass: {
206+
<TClass extends new () => TRegistry[K]>(
207+
constructor: TClass,
208+
dependencies?: undefined,
209+
scope?: Scope
210+
): void;
211+
<TClass extends new (...args: any[]) => TRegistry[K]>(
212+
constructor: TClass,
213+
dependencies: ValidDepsFor<TRegistry, TClass>,
214+
scope?: Scope
215+
): void;
216+
};
140217
};
141218

142219
load<TModuleRegistry>(moduleKey: ModuleKey, module: TypedModule<TModuleRegistry>): void;

0 commit comments

Comments
 (0)