-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat(context): add type as a generic parameter to ctx.get()
and friends
#1050
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
Changes related to Promise vs. PromiseLike were triggered by a compilation error I received:
While I was researching the problem, I found the following content as related. After a week's break, I no longer remember details, but I am still posting the links for posterity.
|
const booterInst2 = await ctx.get(booterKey2); | ||
expect(booterInst.configureCalled).to.be.False(); | ||
expect(booterInst.loadCalled).to.be.False(); | ||
expect(booterInst2.configureCalled).to.be.True(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These assertions are rather unhelpful when they fail:
1) boot-strapper unit tests
only runs booters passed in via BootOptions.filter.booters:
AssertionError: expected false to be true
at Assertion.fail (packages/testlab/node_modules/should/as-function.js:275:17)
at Assertion.value (packages/testlab/node_modules/should/as-function.js:356:19)
at Context.it (packages/boot/test/unit/bootstrapper.unit.ts:4:65)
at <anonymous>
While I was fixing the cause of the test failure, I rewrote all tests to be more helpful when failing:
AssertionError: expected Array [ ] to equal Array [ 'TestBooter:configure' ]
+ expected - actual
[
+ "TestBooter:configure"
]
@@ -32,8 +32,8 @@ export type MapObject<T> = {[name: string]: T}; | |||
* | |||
* @param value The value to check. | |||
*/ | |||
export function isPromise<T>( | |||
value: T | PromiseLike<T>, | |||
export function isPromiseLike<T>( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's the expectation that we want to set for developers, PromiseLike
or Promise
? IIUC, PromiseLike
does not have catch
function. Is it going to be a surprise to developers that want to use compliant promises? Maybe we should switch from PromiseLike
to Promise
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For the record, Promise.prototype.catch()
is available since ES2015.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1 for switching to Promise
instead of us having to do https://github.com/strongloop/loopback-next/pull/1050/files#diff-f726e786caf6fb7f3616d8a58c63b3bfR58 (but that's the only instance I found). Not sure of other reasons.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You are right that Promise.prototype.catch
is available since ES2015. It's the other API, Promise.prototype.finally
that is coming in ES2018 IIRC - see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/finally.
What's the expectation that we want to set for developers,
PromiseLike
orPromise
?
My personal take based on Robustness principle (Be conservative in what you do, be liberal in what you accept from others.):
- When expecting a Promise created by the user/caller, we should allow any
PromiseLike
value. This simplifies integration with existing 3rd party libraries that do not use native promises yet, our own juggler is a prime example. - When returning a Promise to the user/caller, always return a native Promise.
Please note that this is exactly how async/await
works according to the spec - you can await
any promise-like value, async
function always return a native promise.
The changes proposed in this pull request are going in this direction.
Is it going to be a surprise to developers that want to use compliant promises?
IMO, as long as we always return native promises, developers should not face any surprise.
Having said that, I would be surprised if LoopBack did not accept non-native Promises (e.g. as returned by Bluebird) and forced me to convert non-native promises returned by 3rd party modules to native promises.
packages/context/src/context.ts
Outdated
); | ||
return Promise.resolve<T | undefined>(valueOrPromiseLike); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The implementation can be simplified using async await
. For example:
// Implementation
async get<T>(
keyWithPath: string,
optionsOrSession?: ResolutionOptions | ResolutionSession,
): Promise<T | undefined> {
/* istanbul ignore if */
if (debug.enabled) {
debug('Resolving binding: %s', keyWithPath);
}
return await this.getValueOrPromise<T>(
keyWithPath,
optionsOrSession,
);
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we are/were intentionally avoiding async functions in performance-critical code paths. This pull request is also focusing on fixing type definitions, not necessarily rewriting code from promise-like to async/await style.
Having said that, if there is a consensus that Context functions should be rewritten to async/await style as part of this pull request, then I don't mind too much.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On the second thought, I think the actual reason for using Promise.resolve
instead of await
was that I though it's not allowed to await
a non-promise value.
I looked up docs and it looks it's perfectly fine to await a value that's not a promise, see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await:
If the value of the expression following the
await
operator is not aPromise
, it's converted to a resolved Promise.
In that light, I agree ctx.get
should be rewritten to an async function and I'll make the changes ASAP.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice work! There are some minor fixups that you included in this PR, and it'd be great if you can do them in another PR, but I'm ok with including them here.
packages/context/src/context.ts
Outdated
* ```ts | ||
* // get "rest" property from the value bound to "config" | ||
* // use "undefined" when not config was provided | ||
* const config = await ctx.get('config#rest', {optional: true}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shall we modify the code snippet in this example as well? config = await ctx.get<RestComponentConfig>('config#rest', {optional: true});
(see https://github.com/strongloop/loopback-next/blob/master/packages/rest/src/rest-component.ts#L48)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch! I'll edit the snippet.
packages/context/src/context.ts
Outdated
* ```ts | ||
* // get "rest" property from the value bound to "config" | ||
* // use "undefined" when not config was provided | ||
* const config = await ctx.getSync('config#rest', {optional: true}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ditto
@@ -32,8 +32,8 @@ export type MapObject<T> = {[name: string]: T}; | |||
* | |||
* @param value The value to check. | |||
*/ | |||
export function isPromise<T>( | |||
value: T | PromiseLike<T>, | |||
export function isPromiseLike<T>( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1 for switching to Promise
instead of us having to do https://github.com/strongloop/loopback-next/pull/1050/files#diff-f726e786caf6fb7f3616d8a58c63b3bfR58 (but that's the only instance I found). Not sure of other reasons.
@raymondfeng @b-admike thank you for the review. I have addressed all your comments and responded to questions about PromiseLike vs. Promise. LGTY now? |
a0a3397
to
78a85de
Compare
ctx.get()
and friends
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚢🇮🇹
Wow. Very cool PR and feature but I do have a question regarding the breaking change ...
What was the use case for this because it's possible to cast the
|
packages/context/src/context.ts
Outdated
keyWithPath: string, | ||
optionsOrSession?: ResolutionOptions | ResolutionSession, | ||
): ValueOrPromise<BoundValue> { | ||
): T | PromiseLike<T> | undefined { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should it be ValueOrPromise<T | undefined>
? If the optional
is true
, can a value provider return undefined
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a good question. Right now, the code in getValueOrPromise
returns undefined
only when the binding was not found. A provider cannot return undefined
unless it's declared as implements Provider</*...*/ | undefined>
, which is actually a valid usage we need to consider!
Another source of undefined
is getDeepProperty
, which can return undefined
when the deep property does not exist.
I need to take another look at both issues pointed above.
I think that ctx.get
should throw when a deep property does not exist, for consistency with the case when the binding itself was not found. What do you think?
Would you mind if I restrict the changes in this patch to changing the return value of getValueOrPromise
to ValueOrPromise<T | undefined>
, and then look how to further improve type checks (and possibly throw when a deep property was not found) in a follow-up pull request?
Yes, you can always specify the type as const fooBar = ctx.get<any>('foo.bar'); I just want you to be explicit about the fact that you are introducing Also note that you can omit the generic parameter altogether, in which case TypeScript will assume I am intentionally NOT defaulting to
Consider the following snippet, which was the first version you wrote IIRC: const bootstrapper = await this.get('boot.bootstrapper');
const applause = bootstrapper.danceSamba('hey', 'hou'); As a reader, it's difficult to tell that Compare the code above with the following: const productRepository = new DefaultCrudRepository<Product>(db);
const items = await productRepository.find({where: {price: {$lt: 10}}}); There is no type annotation for So I am looking for a solution that will allow us to distinguish between those two cases, accept the second case because we have type information there, reject the first case because it introduces Before my change, it was too easy to miss the fact that Sorry if my comment is repetitive too much, it's a late evening and I need to get this out of my system, so that I can stop thinking about work :) |
For non-optional injections, it's probably better to only throw an error if the binding key cannot be found in the context hierarchy. There are situations where |
…ends 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`.
78a85de
to
6cd7d7c
Compare
I'd like to explore this problem more, but let's keep that out of scope of this pull request. For now, I am going to keep the current implementation that throws only when the binding key cannot be found. |
New changes:
@virkt25 @raymondfeng LGTY now? |
Modify
ctx.get()
and friends to request callers to explicitly specifywhat type they are expecting to receive from the context. This prevents
unintentional introduction of
any
type into our codebase.Consider the following example:
The variable
bootstrapper
has typeany
, which is super easy to misswhen reading/reviewing such code.
Once we introduce
any
, the compiler does not check anything - we caninvoke arbitrary methods with arbitrary parameters, the result of such
call is
any
again. As far as the compiler is concerned, the followingcode is perfectly valid too:
With my commit in place, neither of the examples above compiles and the
first example needs to be fixed as follows:
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:
specification (i.e. has
p.then()
method).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()
andctx.getSync()
require a type now.See the example below for upgrade instructions:
isPromise
was renamed toisPromiseLike
and acts as a type guardfor
PromiseLike
, notPromise
. When upgrading affected code, youneed to determine whether the code was accepting any Promise
implementation (i.e.
PromiseLike
) or only native Promises. In theformer case, you should use
isPromiseLike
and potentially convert theuserland Promise instance to a native Promise via
Promise.resolve(promiseLike)
. In the latter case, you can replaceisPromise(p)
withp instanceof Promise
.Checklist
npm test
passes on your machinepackages/cli
were updatedpackages/example-*
were updated