-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Introduce typed binding key #1169
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
packages/context/src/context.ts
Outdated
| bind(key: string): Binding { | ||
| bind<T = BoundValue>(key: string | BindingKey<T>): Binding<T> { | ||
| if (typeof key !== 'string') { | ||
| key = key.value; |
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.
Discussion point:
I am thinking about two alternatives, I am not sure which one is better:
- keep the current solution where we require the key object to have a
.valueproperty - use a different approach - convert the key object to a string key via
.toString()
The difference is for people using Context in JavaScript codebase, it allows them to use anything as a key, as long as the value can be converted to a string.
Thoughts?
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 the current approach is sufficient requiring a .value property to the key object. However, I see the UX value in us handling it internally with your alternative approach.
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.
One problem I see with our current approach is that two separate objects with key value mapping to the same string would not behave as users may expect it to.
As an aside, did you mean JSON.stringify as opposed to .toString()?
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.
One problem I see with our current approach is that two separate objects with key value mapping to the same string would not behave as users may expect it to.
@shimks I am afraid I don't understand, could you please add more details? What would users expect and what would they get instead?
As an aside, did you mean JSON.stringify as opposed to .toString()?
I meant .toString(). To be more precise, I would use the following conversion for its performance:
key = '' + key;
b-admike
left a comment
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! I'm glad that we don't have to explicitly type out the expected type in .get, and .getSync calls.
packages/context/src/context.ts
Outdated
| bind(key: string): Binding { | ||
| bind<T = BoundValue>(key: string | BindingKey<T>): Binding<T> { | ||
| if (typeof key !== 'string') { | ||
| key = key.value; |
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 the current approach is sufficient requiring a .value property to the key object. However, I see the UX value in us handling it internally with your alternative approach.
| const value = await ctx.get(key); | ||
| // The following line is accessing a String property as a way | ||
| // of verifying the value type at compile time | ||
| expect(value.length).to.equal(5); |
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.
Isn't it also fair to just assert expect(value).to.be.a.String()?
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.
Ignore this comment and the next if the approach above has no way of verifying the type at compile time. If so, I'm curious as to why it works like that
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.
Unfortunately expect(value) accepts any type at compile time, it cannot be used to make any compile-time assertions.
| const value = await ctx.get(key.deepProperty<number>('rest.port')); | ||
| // The following line is accessing a Number property as a way | ||
| // of verifying the value type at compile time | ||
| expect(value.toFixed()).to.equal('80'); |
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.
Same thing as before here but assert for number
packages/context/src/context.ts
Outdated
| bind(key: string): Binding { | ||
| bind<T = BoundValue>(key: string | BindingKey<T>): Binding<T> { | ||
| if (typeof key !== 'string') { | ||
| key = key.value; |
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.
One problem I see with our current approach is that two separate objects with key value mapping to the same string would not behave as users may expect it to.
As an aside, did you mean JSON.stringify as opposed to .toString()?
| // of verifying the value type at compile time | ||
| expect(value.length).to.equal(5); | ||
| }); | ||
|
|
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.
Could we also get a test case for a custom type?
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 kind of a custom type would you like to test?
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 was thinking just any random classes, an empty one would be fine.
packages/context/src/context.ts
Outdated
| * @returns A promise of the bound value. | ||
| */ | ||
| get<T>(keyWithPath: string): Promise<T>; | ||
| get<T>(keyWithPath: string | BindingKey<T>): Promise<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.
Can we get rid of using generics for these functions? Instead of this get<string>('some.key'), can we force users to do this: get(new BindingKey<string>('some.key'))
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.
We could, but I don't want to. Think about people using JavaScript - I prefer ctx.get('some.key') over ctx.get(new BindingKey('some.key')).
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.
People using JavaScript wouldn't get type coersion anyway, so they're free to choose either ways of retrieving their dependencies (I'd think that most would go for the ctx.get('some.key') option). What I don't like about the hard type casting at the function level is that it's something that users could get wrong and not get any errors from. But the same could be said about the change you're introducing, so maybe it's ok?
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.
Well, if you force users to do get(new BindingKey<string>('some.key')) then JavaScript users won't be able to write ctx.get('some.key'). Unless I misunderstood you?
What I don't like about the hard type casting at the function level is that it's something that users could get wrong and not get any errors from.
Could you please post a code snippet demonstrating such invalid usage? I like making APIs difficult to misuse, but I am having difficulties imagining the example you have in mind.
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.
My bad, I meant that we should force users to do this: get(new BindingKey<string>('some.key') OR get('some.key') (notice the lack of generics here). So if they want type coercion, we have them use the BindingKey constructor.
Take a look at this example:
const key = 'key';
const value = 'value';
const b = new Context();
b.bind(value).to(key);
const retrievedValue = b.getSync<number>(key);There is going to be no compilation error here since there is no way of knowing if retrievedValue is actually a number.
Since we're going to be exporting the keys in the form of BindingKey in most of our modules, if users were to use get() to retrieve whatever's bound to the BindingKey they're importing, they'd immediately know what was the type of the value gotten.
|
-1 to introduce runtime overhead (new BindingKey) for typing. Typing enforcement should only incur compile time cost. I also would like to keep the binding key simple as string. The type should NOT be associated with the key, which is the address/uri of the bound value. If we really want to add typing for export class Binding<T = BoundValue> {
// ...
to(val: T): this {
}
toClass(cls: Constructor<T>): this {
}
toDynamicValue(fn: () => T): this {
}
toProvider(provider: Provider<T>): this {
}
}
export class Context {
bind<T>(key: string): Binding<T> {
// ...
}
} |
|
@raymondfeng I have a different opinion here.
Please note that each Assuming each package has ~10 binding keys, the overhead we are talking about is creation of ~10 objects (instead of ~10 string) at startup. That seems rather insignificant to me, I am sure we have much bigger inefficiencies to worry about. Do you see any other possible source of runtime overhead?
My problem with your approach:
The third point is important because the bugs can be subtle and difficult to spot. Consider the following REST binding: export const HOST = new BindingKey<string | undefined>('rest.host');Before my change, there was no indication that const resolve = promisify(dns.resolve);
const host = await ctx.get<string>(RestBindings.HOST);
const records = await resolve(host);
// etc.The compiler will happily accept such code and only at runtime we will learn that HOST may be also undefined in which case our code needs to find the server's host name using a different way. Now consider the same snippet with my proposal in place - the compiler immediately tells the developer that "undefined" is an edge case they must handle: const host = await ctx.get(RestBindings.HOST);
const records = await resolve(host);
// Compiler complains:
// - cannot convert string | undefined to string
// - cannot convert undefined to stringIdeally, I'd like |
|
If the purpose is for pre-defined binding keys as constants, I'm fine with the idea to introduce a The implementation can be simplified as follows: export class BindingKey<T = BoundValue> {
public readonly key: string;
public readonly path?: string;
private constructor(keyWithOptionalPath: string) {
Object.assign(this, Binding.parseKeyWithPath(keyWithOptionalPath));
}
toString() {
return Binding.buildKeyWithPath(this.key, this.path);
}
static create<T = BoundValue>(keyWithOptionalPath: string): BindingKey<T> {
return new BindingKey<T>(keyWithOptionalPath: string);
}
}Then add the following static method to static key<T = BoundValue>(keyWithOptionalPath: string) {
return BindingKey.create<T>(keyWithOptionalPath: string);
}
}The usage will be: In addition, I created #1174 to add optional typing for |
packages/context/src/context.ts
Outdated
| */ | ||
| get<T>( | ||
| keyWithPath: string, | ||
| keyWithPath: string | BindingKey<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.
Maybe it's better to define a type alias as:
export type BindingKeyType<T> = string | BindingKey<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.
I created the type alias as BindingAddress.
const HOST = Binding.key<string | undefined>('rest.host');This unfortunately triggers a problem/limitation of the current TypeScript and TSLint implementations.
I'll stick to |
|
What about |
7ba2618 to
6408ff5
Compare
Fair enough, I can do that. |
4739bc1 to
df583c5
Compare
|
@raymondfeng @b-admike @shimks I have addressed (hopefully all of) your feedback. PTAL again, do the changes look good to you now? |
packages/context/src/BindingKey.ts
Outdated
| return { | ||
| key: keyWithPath.substr(0, index).trim(), | ||
| path: keyWithPath.substr(index + 1), | ||
| }; |
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 should turn BindingKey into {key, path?}.
packages/context/src/BindingKey.ts
Outdated
| return new BindingKey<ValueType>(key); | ||
| } | ||
|
|
||
| private constructor(public key: string) {} |
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.
Let's allow an optional path:
private constructor(public readonly key: string, public readonly path?: string ) {}
packages/context/src/BindingKey.ts
Outdated
| * @param key The binding key. | ||
| */ | ||
| public static create<ValueType>(key: string): BindingKey<ValueType> { | ||
| return new BindingKey<ValueType>(key); |
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.
Let's change key to keyWithPath and parse it into {key, path?}.
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'll allow the following flavors:
BindingKey.create('rest');
BindingKey.create('rest#port');
BindingKey.create('rest', 'port');
packages/context/src/binding.ts
Outdated
| this.type = BindingType.PROVIDER; | ||
| this._getValue = (ctx, session) => { | ||
| const providerOrPromise = instantiateClass<Provider<T>>( | ||
| const providerOrPromise = instantiateClass<Provider<BoundValue>>( |
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.
Why BoundValue instead of 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.
This looks like an unintended change, I'll revert it.
packages/context/src/context.ts
Outdated
| */ | ||
| bind<T = BoundValue>(key: string): Binding<T> { | ||
| bind<ValueType = BoundValue>( | ||
| key: string | BindingKey<ValueType>, |
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.
Can we use BindingAddress<ValueType> here?
packages/context/src/BindingKey.ts
Outdated
| } | ||
|
|
||
| /** | ||
| * Get a bnding address for retrieving a deep property of the object |
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.
Typo: bnding -> binding
packages/context/src/BindingKey.ts
Outdated
| return this.key; | ||
| } | ||
|
|
||
| static asString<T>(address: BindingAddress<T>): string { |
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 helper method is unnecessary as address.toString() will work for both string and BindingKey.
|
@raymondfeng comments addressed, PTAL again. |
shimks
left a comment
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'm sure you already have this in mind, but once @raymondfeng is happy with the changes, please make sure to check the docs/cli templates/README so that the code samples utilize the the feature of this PR. LGTM otherwise
| this.providers = { | ||
| [AuthenticationBindings.AUTH_ACTION]: AuthenticationProvider, | ||
| [AuthenticationBindings.METADATA]: AuthMetadataProvider, | ||
| [AuthenticationBindings.AUTH_ACTION.toString()]: AuthenticationProvider, |
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 find .toString to be messy. Any reason we can't just access the key property of the BindingKey?
AuthenticationBindings.AUTH_ACTION.key
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 to use AuthenticationBindings.AUTH_ACTION.key.
|
+1 for no longer needing to add types when using Can someone please explain why we must use a factory method for
|
A static factory method hides the implementation details and give us more flexibility. For example, we might want to cache instances of BindingKey(s). |
|
The PR mostly LGTM now. It would be nice to use Please fix the commit log: |
Can't this be done in the constructor? |
No. A constructor will always create a new instance. |
ccffe17 to
eba76e6
Compare
Thank you for reminding me of this part! The documentation update turned out to be more involved than I anticipated, I discovered quite few outdated pages. I updated them as part of my work here. I am not entirely happy with squashing possibly unrelated doc updates with the changes adding typed binding keys, but then I don't know how many of those doc updates are possible before the typed binding keys are available. The commit ccffe17 contains all changes I made since the last round of review.
I'll fix it once this PR gets approved and the history is cleaned up. @raymondfeng @b-admike @shimks @virkt25 LGTY now? |
shimks
left a comment
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.
👍
docs/site/Creating-components.md
Outdated
| ``` | ||
|
|
||
| We recommend to component authors to use | ||
| [Typed binding keys](./Context.html#encoding-value-types-in-binding-keys) |
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.
.html -> .md
packages/authentication/README.md
Outdated
| // src/application.ts | ||
| import {BootMixin, Binding, Booter} from '@loopback/boot'; | ||
| import {RestApplication, RestServer} from '@loopback/rest'; | ||
| import {RestApplication, RestServeri, RestBindings} from '@loopback/rest'; |
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.
RestServer
Modify Context, Binding, and related methods to allow developers to add a type information to individual binding keys. These binding keys will allow the compiler to verify that a correct value is passed to binding setters (`.to()`, `.toClass()`, etc.) and automatically infer the type of the value returned by `ctx.get()` and `ctx.getSync()`. Bring the documentation in sync with the actual implementation in code. Rename `AuthenticationProvider` to `AuthenticateActionProvider`
eba76e6 to
af8d3d5
Compare
Modify Context, Binding, and related methods to allow developers to add a type information to individual binding keys. These binding keys will allow the compiler to verify that a correct value is passed to binding setters (
.to(),.toClass(), etc.) and automatically infer the type of the value returned byctx.get()andctx.getSync().This is an early prototype to discuss whether we want to follow the proposed direction.@raymondfeng @strongloop/lb-next-dev @strongloop/loopback-next thoughts?
Checklist
npm testpasses on your machinepackages/cliwere updatedpackages/example-*were updated