Skip to content

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

Merged
merged 2 commits into from
Mar 1, 2018

Conversation

bajtos
Copy link
Member

@bajtos bajtos commented Feb 26, 2018

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:

- 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.

Checklist

  • npm test passes on your machine
  • New tests added or existing tests modified to cover all changes
  • Code conforms with the style guide
  • Related API Documentation was updated
  • (n/a) Affected artifact templates in packages/cli were updated
  • Affected example projects in packages/example-* were updated

@bajtos bajtos added developer-experience Issues affecting ease of use and overall experience of LB users breaking-change labels Feb 26, 2018
@bajtos bajtos added this to the February 2018 milestone Feb 26, 2018
@bajtos bajtos self-assigned this Feb 26, 2018
@bajtos
Copy link
Member Author

bajtos commented Feb 26, 2018

Changes related to Promise vs. PromiseLike were triggered by a compilation error I received:

Property 'catch' does not exist on type 'PromiseLike'

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();
Copy link
Member Author

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>(
Copy link
Contributor

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.

Copy link
Contributor

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.

Copy link
Contributor

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.

Copy link
Member Author

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 or Promise?

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.

);
return Promise.resolve<T | undefined>(valueOrPromiseLike);
Copy link
Contributor

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,
    );
  }

Copy link
Member Author

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.

Copy link
Member Author

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 a Promise, 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.

Copy link
Contributor

@b-admike b-admike left a 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.

* ```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});
Copy link
Contributor

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)

Copy link
Member Author

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.

* ```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});
Copy link
Contributor

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>(
Copy link
Contributor

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.

@bajtos
Copy link
Member Author

bajtos commented Feb 27, 2018

@raymondfeng @b-admike thank you for the review. I have addressed all your comments and responded to questions about PromiseLike vs. Promise.

LGTY now?

@bajtos bajtos force-pushed the feat/typed-context-get branch from a0a3397 to 78a85de Compare February 27, 2018 09:26
@bajtos bajtos changed the title Feat/typed context get feat(context): add type as a generic parameter to ctx.get() and friends Feb 27, 2018
Copy link
Contributor

@b-admike b-admike left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚢🇮🇹

@virkt25
Copy link
Contributor

virkt25 commented Feb 27, 2018

Wow. Very cool PR and feature but I do have a question regarding the breaking change ...

  • Is it possible to make the type information optional? I don't think it's good UX to require the user to always import the Class / Function / Value type being retrieved when using .get. If I'm already importing the Class / Function then in some cases I might as well just use that class as is (but yea DI has other advantages).

What was the use case for this because it's possible to cast the get into the type you want as done in Bootstrapper.

const bootstrapper: Bootstrapper = await this.get('boot.bootstrapper');
const applause = bootstrapper.danceSamba('hey', 'hou'); // Compiler complains "Property 'danceSamba' does not exist on type 'Bootstrapper"

keyWithPath: string,
optionsOrSession?: ResolutionOptions | ResolutionSession,
): ValueOrPromise<BoundValue> {
): T | PromiseLike<T> | undefined {
Copy link
Contributor

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?

Copy link
Member Author

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?

@bajtos
Copy link
Member Author

bajtos commented Feb 27, 2018

@virkt25

Is it possible to make the type information optional? I don't think it's good UX to require the user to always import the Class / Function / Value type being retrieved when using .get. If I'm already importing the Class / Function then in some cases I might as well just use that class as is (but yea DI has other advantages).

Yes, you can always specify the type as any:

const fooBar = ctx.get<any>('foo.bar');

I just want you to be explicit about the fact that you are introducing any.

Also note that you can omit the generic parameter altogether, in which case TypeScript will assume {} (an empty object).

I am intentionally NOT defaulting to any, because that will effectively turn off compiler checks. (I have tried that early on while working on this pull request.) I want the compiler to tell me when I forgot to specify the type and introduce any as a result.

What was the use case for this because it's possible to cast the get into the type you want as done in Bootstrapper.

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 bootstrapper is any in this case, because we are used to the fact that TypeScript can infer variable types using the right hand side of the initialization assignment. I think you, as a person writing this code, did not realize introduction of any either, did you?

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 items, and yet they have a proper type inferred by TypeScript (Product[]).

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 any.

Before my change, it was too easy to miss the fact that ctx.get is introducing any. With my change in place, the compiler will complain if you forget to specify the result type of ctx.get. If you don't want to specify the type, then you can use any, but you must be explicit about such choice.

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 :)

@raymondfeng
Copy link
Contributor

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 undefined is a valid value and it's hard to justify whether a undefined does not fulfill the dependency.

…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`.
@bajtos bajtos force-pushed the feat/typed-context-get branch from 78a85de to 6cd7d7c Compare March 1, 2018 11:02
@bajtos
Copy link
Member Author

bajtos commented Mar 1, 2018

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 undefined is a valid value and it's hard to justify whether a undefined does not fulfill the dependency.

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.

@bajtos
Copy link
Member Author

bajtos commented Mar 1, 2018

New changes:

@virkt25 @raymondfeng LGTY now?

@bajtos bajtos modified the milestones: February 2018, March 2018 Mar 1, 2018
@raymondfeng raymondfeng merged commit 1d9979f into master Mar 1, 2018
@raymondfeng raymondfeng deleted the feat/typed-context-get branch March 1, 2018 16:26
virkt25 added a commit that referenced this pull request Mar 12, 2018
came across this while reviewing the code / readme. Making separate PRs for easier review. This usage was introduced in #1050

connected to #988
virkt25 added a commit that referenced this pull request Mar 13, 2018
came across this while reviewing the code / readme. Making separate PRs for easier review. This usage was introduced in #1050

connected to #988
virkt25 added a commit that referenced this pull request Mar 13, 2018
came across this while reviewing the code / readme. Making separate PRs for easier review. This usage was introduced in #1050

connected to #988
virkt25 added a commit that referenced this pull request Mar 13, 2018
came across this while reviewing the code / readme. Making separate PRs for easier review. This usage was introduced in #1050

connected to #988
virkt25 added a commit that referenced this pull request Mar 13, 2018
came across this while reviewing the code / readme. Making separate PRs for easier review. This usage was introduced in #1050

connected to #988
virkt25 added a commit that referenced this pull request Mar 14, 2018
came across this while reviewing the code / readme. Making separate PRs for easier review. This usage was introduced in #1050

connected to #988
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
breaking-change developer-experience Issues affecting ease of use and overall experience of LB users
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants