Skip to content

Commit 3db53b7

Browse files
committed
feat(context): add type as a generic parameter to ctx.get() and friends
Modify `ctx.get()` and friends to request callers to explicitly specify what type they are expecting to receive from the context. This prevents unintentional introduction of `any` type into our codebase. Consider the following example: const bootstrapper = ctx.get('core.Bootstrapper'); bootstrapper.run(options); The variable `bootstrapper` has type `any`, which is super easy to miss when reading/reviewing such code. Once we introduce `any`, the compiler does not check anything - we can invoke arbitrary methods with arbitrary parameters, the result of such call is `any` again. As far as the compiler is concerned, the following code is perfectly valid too: const bootstrapper = ctx.get('core.Bootstrapper'); const applause = bootstrapper.danceSamba('hey', 'hou'); applause.play(); With my commit in place, neither of the examples above compiles and the first example needs to be fixed as follows: const bootstrapper = ctx.get<Bootstrapper>('core.Bootstrapper'); bootstrapper.run(options); While working on this pull request, I discovered and fixed a problem related to how we treat Promises. In TypeScript, there are two entities describing Promises: - PromiseLike interface describes any object conforming to Promise/A+ specification (i.e. has `p.then()` method). - Promise class describes the native Promise class. In ES2018, this class has additional methods compared to PromiseLike, e.g. `p.catch()`. In our code, there were places where the type definitions were assuming the native Promise, but the API allowed consumers to provide any PromiseLike value. For example, loopback-datasource-juggler always returns Bluebird promises. This commit fixes type annotations to use PromiseLike in all places that a user-provided promise value can enter. BREAKING CHANGE: `ctx.get()` and `ctx.getSync()` require a type now. See the example below for upgrade instructions: ```diff - const c: MyController = await ctx.get('MyController'); + const c = await ctx.get<MyController>('MyController'); ``` `isPromise` was renamed to `isPromiseLike` and acts as a type guard for `PromiseLike`, not `Promise`. When upgrading affected code, you need to determine whether the code was accepting any Promise implementation (i.e. `PromiseLike`) or only native Promises. In the former case, you should use `isPromiseLike` and potentially convert the userland Promise instance to a native Promise via `Promise.resolve(promiseLike)`. In the latter case, you can replace `isPromise(p)` with `p instanceof Promise`.
1 parent ea80b0a commit 3db53b7

25 files changed

+225
-123
lines changed

packages/authentication/test/unit/authenticate-action.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,10 @@ describe('AuthenticationProvider', () => {
6060
.bind(AuthenticationBindings.AUTH_ACTION)
6161
.toProvider(AuthenticationProvider);
6262
const request = <ParsedRequest>{};
63-
const authenticate = await context.get(
63+
const authenticate = await context.get<AuthenticateFn>(
6464
AuthenticationBindings.AUTH_ACTION,
6565
);
66-
const user: UserProfile = await authenticate(request);
66+
const user: UserProfile | undefined = await authenticate(request);
6767
expect(user).to.be.equal(mockUser);
6868
});
6969

@@ -73,7 +73,7 @@ describe('AuthenticationProvider', () => {
7373
context
7474
.bind(AuthenticationBindings.AUTH_ACTION)
7575
.toProvider(AuthenticationProvider);
76-
const authenticate = await context.get(
76+
const authenticate = await context.get<AuthenticateFn>(
7777
AuthenticationBindings.AUTH_ACTION,
7878
);
7979
const request = <ParsedRequest>{};
@@ -92,7 +92,7 @@ describe('AuthenticationProvider', () => {
9292
context
9393
.bind(AuthenticationBindings.AUTH_ACTION)
9494
.toProvider(AuthenticationProvider);
95-
const authenticate = await context.get(
95+
const authenticate = await context.get<AuthenticateFn>(
9696
AuthenticationBindings.AUTH_ACTION,
9797
);
9898
const request = <ParsedRequest>{};

packages/boot/src/bootstrapper.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,10 @@ export class Bootstrapper {
109109

110110
// Resolve Booter Instances
111111
const booterInsts = await resolveList(filteredBindings, binding =>
112-
bootCtx.get(binding.key),
112+
// We cannot use Booter interface here because "filter.booters"
113+
// allows arbitrary string values, not only the phases defined
114+
// by Booter interface
115+
bootCtx.get<{[phase: string]: () => Promise<void>}>(binding.key),
113116
);
114117

115118
// Run phases of booters

packages/boot/test/unit/bootstrapper.unit.ts

Lines changed: 39 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -13,49 +13,50 @@ describe('boot-strapper unit tests', () => {
1313
let app: BootApp;
1414
let bootstrapper: Bootstrapper;
1515
const booterKey = `${BootBindings.BOOTER_PREFIX}.TestBooter`;
16-
const booterKey2 = `${BootBindings.BOOTER_PREFIX}.TestBooter2`;
16+
const anotherBooterKey = `${BootBindings.BOOTER_PREFIX}.AnotherBooter`;
1717

1818
beforeEach(getApplication);
1919
beforeEach(getBootStrapper);
2020

2121
it('finds and runs registered booters', async () => {
2222
const ctx = await bootstrapper.boot();
23-
const booterInst = await ctx.get(booterKey);
24-
expect(booterInst.configureCalled).to.be.True();
25-
expect(booterInst.loadCalled).to.be.True();
23+
const booterInst = await ctx.get<TestBooter>(booterKey);
24+
expect(booterInst.phasesCalled).to.eql([
25+
'TestBooter:configure',
26+
'TestBooter:load',
27+
]);
2628
});
2729

2830
it('binds booters passed in BootExecutionOptions', async () => {
29-
const ctx = await bootstrapper.boot({booters: [TestBooter2]});
30-
const booterInst2 = await ctx.get(booterKey2);
31-
expect(booterInst2).to.be.instanceof(TestBooter2);
32-
expect(booterInst2.configureCalled).to.be.True();
31+
const ctx = await bootstrapper.boot({booters: [AnotherBooter]});
32+
const anotherBooterInst = await ctx.get<AnotherBooter>(anotherBooterKey);
33+
expect(anotherBooterInst).to.be.instanceof(AnotherBooter);
34+
expect(anotherBooterInst.phasesCalled).to.eql(['AnotherBooter:configure']);
3335
});
3436

3537
it('no booters run when BootOptions.filter.booters is []', async () => {
3638
const ctx = await bootstrapper.boot({filter: {booters: []}});
37-
const booterInst = await ctx.get(booterKey);
38-
expect(booterInst.configureCalled).to.be.False();
39-
expect(booterInst.loadCalled).to.be.False();
39+
const booterInst = await ctx.get<TestBooter>(booterKey);
40+
expect(booterInst.phasesCalled).to.eql([]);
4041
});
4142

4243
it('only runs booters passed in via BootOptions.filter.booters', async () => {
4344
const ctx = await bootstrapper.boot({
44-
booters: [TestBooter2],
45-
filter: {booters: ['TestBooter2']},
45+
booters: [AnotherBooter],
46+
filter: {booters: ['AnotherBooter']},
4647
});
47-
const booterInst = await ctx.get(booterKey);
48-
const booterInst2 = await ctx.get(booterKey2);
49-
expect(booterInst.configureCalled).to.be.False();
50-
expect(booterInst.loadCalled).to.be.False();
51-
expect(booterInst2.configureCalled).to.be.True();
48+
const testBooterInst = await ctx.get<TestBooter>(booterKey);
49+
const anotherBooterInst = await ctx.get<AnotherBooter>(anotherBooterKey);
50+
const phasesCalled = testBooterInst.phasesCalled.concat(
51+
anotherBooterInst.phasesCalled,
52+
);
53+
expect(phasesCalled).to.eql(['AnotherBooter:configure']);
5254
});
5355

5456
it('only runs phases passed in via BootOptions.filter.phases', async () => {
5557
const ctx = await bootstrapper.boot({filter: {phases: ['configure']}});
56-
const booterInst = await ctx.get(booterKey);
57-
expect(booterInst.configureCalled).to.be.True();
58-
expect(booterInst.loadCalled).to.be.False();
58+
const booterInst = await ctx.get<TestBooter>(booterKey);
59+
expect(booterInst.phasesCalled).to.eql(['TestBooter:configure']);
5960
});
6061

6162
/**
@@ -77,24 +78,37 @@ describe('boot-strapper unit tests', () => {
7778
* A TestBooter for testing purposes. Implements configure and load.
7879
*/
7980
class TestBooter implements Booter {
80-
configureCalled = false;
81-
loadCalled = false;
81+
private configureCalled = false;
82+
private loadCalled = false;
8283
async configure() {
8384
this.configureCalled = true;
8485
}
8586

8687
async load() {
8788
this.loadCalled = true;
8889
}
90+
91+
get phasesCalled() {
92+
const result = [];
93+
if (this.configureCalled) result.push('TestBooter:configure');
94+
if (this.loadCalled) result.push('TestBooter:load');
95+
return result;
96+
}
8997
}
9098

9199
/**
92100
* A TestBooter for testing purposes. Implements configure.
93101
*/
94-
class TestBooter2 implements Booter {
95-
configureCalled = false;
102+
class AnotherBooter implements Booter {
103+
private configureCalled = false;
96104
async configure() {
97105
this.configureCalled = true;
98106
}
107+
108+
get phasesCalled() {
109+
const result = [];
110+
if (this.configureCalled) result.push('AnotherBooter:configure');
111+
return result;
112+
}
99113
}
100114
});

packages/build/config/tslint.build.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212
// These rules catch common errors in JS programming or otherwise
1313
// confusing constructs that are prone to producing bugs.
1414

15-
"await-promise": true,
15+
// User-land promises like Bluebird implement "PromiseLike" (not "Promise")
16+
// interface only. The string "PromiseLike" bellow is needed to
17+
// tell tslint that it's ok to `await` such promises.
18+
"await-promise": [true, "PromiseLike"],
1619
"no-floating-promises": true,
1720
"no-unused-variable": true,
1821
"no-void-expression": [true, "ignore-arrow-function-shorthand"]

packages/context/src/binding.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {ResolutionSession} from './resolution-session';
88
import {instantiateClass} from './resolver';
99
import {
1010
Constructor,
11-
isPromise,
11+
isPromiseLike,
1212
BoundValue,
1313
ValueOrPromise,
1414
} from './value-promise';
@@ -176,7 +176,7 @@ export class Binding {
176176
): ValueOrPromise<BoundValue> {
177177
// Initialize the cache as a weakmap keyed by context
178178
if (!this._cache) this._cache = new WeakMap<Context, BoundValue>();
179-
if (isPromise(result)) {
179+
if (isPromiseLike(result)) {
180180
if (this.scope === BindingScope.SINGLETON) {
181181
// Cache the value at owning context level
182182
result = result.then(val => {
@@ -294,7 +294,7 @@ export class Binding {
294294
* ```
295295
*/
296296
to(value: BoundValue): this {
297-
if (isPromise(value)) {
297+
if (isPromiseLike(value)) {
298298
// Promises are a construct primarily intended for flow control:
299299
// In an algorithm with steps 1 and 2, we want to wait for the outcome
300300
// of step 1 before starting step 2.
@@ -380,7 +380,7 @@ export class Binding {
380380
ctx!,
381381
session,
382382
);
383-
if (isPromise(providerOrPromise)) {
383+
if (isPromiseLike(providerOrPromise)) {
384384
return providerOrPromise.then(p => p.value());
385385
} else {
386386
return providerOrPromise.value();

packages/context/src/context.ts

Lines changed: 83 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,7 @@
44
// License text available at https://opensource.org/licenses/MIT
55

66
import {Binding} from './binding';
7-
import {
8-
isPromise,
9-
BoundValue,
10-
ValueOrPromise,
11-
getDeepProperty,
12-
} from './value-promise';
7+
import {isPromiseLike, getDeepProperty} from './value-promise';
138
import {ResolutionOptions, ResolutionSession} from './resolution-session';
149

1510
import {v1 as uuidv1} from 'uuid';
@@ -178,8 +173,8 @@ export class Context {
178173
}
179174

180175
/**
181-
* Get the value bound to the given key, optionally return a (deep) property
182-
* of the bound value.
176+
* Get the value bound to the given key, throw an error when no value was
177+
* bound for the given key.
183178
*
184179
* @example
185180
*
@@ -197,22 +192,49 @@ export class Context {
197192
*
198193
* @param keyWithPath The binding key, optionally suffixed with a path to the
199194
* (deeply) nested property to retrieve.
195+
* @returns A promise of the bound value.
196+
*/
197+
get<T>(keyWithPath: string): Promise<T>;
198+
199+
/**
200+
* Get the value bound to the given key, optionally return a (deep) property
201+
* of the bound value.
202+
*
203+
* @example
204+
*
205+
* ```ts
206+
* // get "rest" property from the value bound to "config"
207+
* // use "undefined" when not config was provided
208+
* const config = await ctx.get('config#rest', {optional: true});
209+
* ```
210+
*
211+
* @param keyWithPath The binding key, optionally suffixed with a path to the
212+
* (deeply) nested property to retrieve.
200213
* @param optionsOrSession Options or session for resolution. An instance of
201214
* `ResolutionSession` is accepted for backward compatibility.
202-
* @returns A promise of the bound value.
215+
* @returns A promise of the bound value, or a promise of undefined when
216+
* the optional binding was not found.
203217
*/
204-
get(
218+
get<T>(
205219
keyWithPath: string,
206220
optionsOrSession?: ResolutionOptions | ResolutionSession,
207-
): Promise<BoundValue> {
221+
): Promise<T | undefined>;
222+
223+
// Implementation
224+
get<T>(
225+
keyWithPath: string,
226+
optionsOrSession?: ResolutionOptions | ResolutionSession,
227+
): Promise<T | undefined> {
208228
/* istanbul ignore if */
209229
if (debug.enabled) {
210230
debug('Resolving binding: %s', keyWithPath);
211231
}
212232
try {
213-
return Promise.resolve(
214-
this.getValueOrPromise(keyWithPath, optionsOrSession),
233+
const valueOrPromiseLike = this.getValueOrPromise<T>(
234+
keyWithPath,
235+
optionsOrSession,
215236
);
237+
return Promise.resolve<T | undefined>(valueOrPromiseLike);
216238
} catch (err) {
217239
return Promise.reject(err);
218240
}
@@ -242,20 +264,50 @@ export class Context {
242264
* `ResolutionSession` is accepted for backward compatibility.
243265
* @returns A promise of the bound value.
244266
*/
245-
getSync(
267+
getSync<T>(keyWithPath: string): T;
268+
269+
/**
270+
* Get the synchronous value bound to the given key, optionally
271+
* return a (deep) property of the bound value.
272+
*
273+
* This method throws an error if the bound value requires async computation
274+
* (returns a promise). You should never rely on sync bindings in production
275+
* code.
276+
*
277+
* @example
278+
*
279+
* ```ts
280+
* // get "rest" property from the value bound to "config"
281+
* // use "undefined" when not config was provided
282+
* const config = await ctx.getSync('config#rest', {optional: true});
283+
* ```
284+
*
285+
* @param keyWithPath The binding key, optionally suffixed with a path to the
286+
* (deeply) nested property to retrieve.
287+
* * @param optionsOrSession Options or session for resolution. An instance of
288+
* `ResolutionSession` is accepted for backward compatibility.
289+
* @returns The bound value, or undefined when an optional binding was not found.
290+
*/
291+
getSync<T>(
246292
keyWithPath: string,
247293
optionsOrSession?: ResolutionOptions | ResolutionSession,
248-
): BoundValue {
294+
): T | undefined;
295+
296+
// Implementation
297+
getSync<T>(
298+
keyWithPath: string,
299+
optionsOrSession?: ResolutionOptions | ResolutionSession,
300+
): T | undefined {
249301
/* istanbul ignore if */
250302
if (debug.enabled) {
251303
debug('Resolving binding synchronously: %s', keyWithPath);
252304
}
253-
const valueOrPromise = this.getValueOrPromise(
305+
const valueOrPromise = this.getValueOrPromise<T>(
254306
keyWithPath,
255307
optionsOrSession,
256308
);
257309

258-
if (isPromise(valueOrPromise)) {
310+
if (isPromiseLike(valueOrPromise)) {
259311
throw new Error(
260312
`Cannot get ${keyWithPath} synchronously: the value is a promise`,
261313
);
@@ -326,16 +378,25 @@ export class Context {
326378
* on how the binding was configured.
327379
* @internal
328380
*/
329-
getValueOrPromise(
381+
getValueOrPromise<T>(
330382
keyWithPath: string,
331383
optionsOrSession?: ResolutionOptions | ResolutionSession,
332-
): ValueOrPromise<BoundValue> {
384+
): T | PromiseLike<T> | undefined {
385+
// ^^^ Note that boundValue can be any Promise-like implementation,
386+
// not necessarily the native Promise instance. As such, it may be
387+
// missing newer APIs like Promise#catch() and cannot be casted
388+
// directly to a Promise. Callers of this method should use
389+
// Promise.resolve() to convert the result into a native Promise.
333390
const {key, path} = Binding.parseKeyWithPath(keyWithPath);
391+
392+
// backwards compatibility
334393
if (optionsOrSession instanceof ResolutionSession) {
335394
optionsOrSession = {session: optionsOrSession};
336395
}
396+
337397
const binding = this.getBinding(key, optionsOrSession);
338398
if (binding == null) return undefined;
399+
339400
const boundValue = binding.getValue(
340401
this,
341402
optionsOrSession && optionsOrSession.session,
@@ -344,11 +405,11 @@ export class Context {
344405
return boundValue;
345406
}
346407

347-
if (isPromise(boundValue)) {
348-
return boundValue.then(v => getDeepProperty(v, path));
408+
if (isPromiseLike(boundValue)) {
409+
return boundValue.then(v => getDeepProperty(v, path) as T);
349410
}
350411

351-
return getDeepProperty(boundValue, path);
412+
return getDeepProperty(boundValue, path) as T;
352413
}
353414

354415
/**

packages/context/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
export * from '@loopback/metadata';
77

88
export {
9-
isPromise,
9+
isPromiseLike,
1010
BoundValue,
1111
Constructor,
1212
ValueOrPromise,

0 commit comments

Comments
 (0)