Skip to content

Commit 4c03819

Browse files
committed
refactor: enable type checks inside mixin classes
Introduce a new type helper `MixinTarget` allowing mixin functions to accept a type that describes public members of the target class only. This is working around the current TypeScript limitations. Rework all existing mixins and the related documentation to use `MixinTarget<RealClass>` instead of `Constructor<any>`. Fix any errors discovered by the compiler after enabling type checks. Signed-off-by: Miroslav Bajtoš <[email protected]>
1 parent bcd033f commit 4c03819

File tree

15 files changed

+156
-57
lines changed

15 files changed

+156
-57
lines changed

docs/site/Creating-components.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ The following snippet is an abbreviated function
364364
{% include code-caption.html content="src/mixins/repository.mixin.ts" %}
365365

366366
```ts
367-
export function RepositoryMixin<T extends Class<any>>(superClass: T) {
367+
export function RepositoryMixin<T extends MixinTarget<Application>>(superClass: T) {
368368
return class extends superClass {
369369
constructor(...args: any[]) {
370370
super(...args);

docs/site/Mixin.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ Define mixin `TimeStampMixin`:
5454
```ts
5555
import {Class} from '@loopback/repository';
5656

57-
export function TimeStampMixin<T extends Class<any>>(baseClass: T) {
57+
export function TimeStampMixin<T extends MixinTarget<Object>>(baseClass: T) {
5858
return class extends baseClass {
5959
// add a new property `createdAt`
6060
public createdAt: Date;
@@ -76,7 +76,7 @@ And define mixin `LoggerMixin`:
7676
```ts
7777
import {Class} from '@loopback/repository';
7878

79-
function LoggerMixin<T extends Class<any>>(baseClass: T) {
79+
function LoggerMixin<T extends MixinTarget<Object>>(baseClass: T) {
8080
return class extends baseClass {
8181
// add a new method `log()`
8282
log(str: string) {

docs/site/Testing-Your-Extensions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ class. Following is an example for an integration test for a Mixin:
234234

235235
```ts
236236
import {Constructor} from '@loopback/context';
237-
export function TimeMixin<T extends Constructor<any>>(superClass: T) {
237+
export function TimeMixin<T extends MixinTarget<Object>>(superClass: T) {
238238
return class extends superClass {
239239
constructor(...args: any[]) {
240240
super(...args);

docs/site/migration/models/mixins.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ This document will guide you in migrating custom model mixins, and custom
1616
method/remote method mixins in LoopBack 3 to their equivalent implementations in
1717
LoopBack 4.
1818

19-
For an understanding of how models in LoopBack 3 are now architectually
19+
For an understanding of how models in LoopBack 3 are now architecturally
2020
decoupled into 3 classes (model, repository, and controller) please read
2121
[Migrating custom model methods](./methods.md).
2222

@@ -169,7 +169,7 @@ import {property, Model} from '@loopback/repository';
169169
* @param superClass - Base Class
170170
* @typeParam T - Model class
171171
*/
172-
export function AddCategoryPropertyMixin<T extends Constructor<Model>>(
172+
export function AddCategoryPropertyMixin<T extends MixinTarget<Model>>(
173173
superClass: T,
174174
) {
175175
class MixedModel extends superClass {
@@ -447,7 +447,7 @@ import {FindByTitle} from './find-by-title-interface';
447447
*/
448448
export function FindByTitleRepositoryMixin<
449449
M extends Model & {title: string},
450-
R extends Constructor<CrudRepository<M>>
450+
R extends MixinTarget<CrudRepository<M>>
451451
>(superClass: R) {
452452
class MixedRepository extends superClass implements FindByTitle<M> {
453453
async findByTitle(title: string): Promise<M[]> {
@@ -552,10 +552,12 @@ export interface FindByTitleControllerMixinOptions {
552552
*/
553553
export function FindByTitleControllerMixin<
554554
M extends Model,
555-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
556-
T extends Constructor<any> = Constructor<object>
555+
T extends MixinTarget<object>
557556
>(superClass: T, options: FindByTitleControllerMixinOptions) {
558557
class MixedController extends superClass implements FindByTitle<M> {
558+
// Value will be provided by the subclassed controller class
559+
repository: FindByTitle<M>;
560+
559561
@get(`${options.basePath}/findByTitle/{title}`, {
560562
responses: {
561563
'200': {
@@ -587,7 +589,7 @@ mixin class factory function needs to accept some options. We defined an
587589
interface `FindByTitleControllerMixinOptions` to allow for this.
588590

589591
It is also a good idea to give the injected repository (in the controller super
590-
class) a generic name like `this.respository` to keep things simple in the mixin
592+
class) a generic name like `this.repository` to keep things simple in the mixin
591593
class factory function.
592594

593595
#### Generating A Controller Via The CLI

examples/log-extension/README.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -228,12 +228,11 @@ providing it via `ApplicationOptions` or using a helper method
228228
`app.logLevel(level: number)`.
229229

230230
```ts
231-
import {Constructor} from '@loopback/context';
231+
import {MixinTarget, Application} from '@loopback/core';
232232
import {EXAMPLE_LOG_BINDINGS} from '../keys';
233233
import {LogComponent} from '../component';
234234

235-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
236-
export function LogMixin<T extends Constructor<any>>(superClass: T) {
235+
export function LogMixin<T extends MixinTarget<Application>>(superClass: T) {
237236
return class extends superClass {
238237
// eslint-disable-next-line @typescript-eslint/no-explicit-any
239238
constructor(...args: any[]) {

examples/log-extension/src/mixins/log.mixin.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
// This file is licensed under the MIT License.
44
// License text available at https://opensource.org/licenses/MIT
55

6-
import {Constructor} from '@loopback/context';
7-
import {EXAMPLE_LOG_BINDINGS, LOG_LEVEL} from '../keys';
6+
import {Application, MixinTarget} from '@loopback/core';
87
import {LogComponent} from '../component';
8+
import {EXAMPLE_LOG_BINDINGS, LOG_LEVEL} from '../keys';
99

1010
/**
1111
* A mixin class for Application that can bind logLevel from `options`.
@@ -17,8 +17,7 @@ import {LogComponent} from '../component';
1717
* class MyApplication extends LogMixin(Application) {}
1818
* ```
1919
*/
20-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
21-
export function LogMixin<T extends Constructor<any>>(superClass: T) {
20+
export function LogMixin<T extends MixinTarget<Application>>(superClass: T) {
2221
return class extends superClass {
2322
// A mixin class has to take in a type any[] argument!
2423
// eslint-disable-next-line @typescript-eslint/no-explicit-any

packages/boot/src/mixins/boot.mixin.ts

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55

66
import {
77
Binding,
8+
BindingFromClassOptions,
89
BindingScope,
910
Constructor,
1011
Context,
1112
createBindingFromClass,
1213
} from '@loopback/context';
14+
import {Application, Component, MixinTarget} from '@loopback/core';
1315
import {BootComponent} from '../boot.component';
1416
import {Bootstrapper} from '../bootstrapper';
1517
import {BootBindings, BootTags} from '../keys';
@@ -30,18 +32,8 @@ export {Binding};
3032
* - Provides the `booter()` convenience method to bind a Booter(s) to the Application
3133
* - Override `component()` to call `mountComponentBooters`
3234
* - Adds `mountComponentBooters` which binds Booters to the application from `component.booters[]`
33-
*
34-
* ******************** NOTE ********************
35-
* Trying to constrain the type of this Mixin (or any Mixin) will cause errors.
36-
* For example, constraining this Mixin to type Application require all types using by
37-
* Application to be imported (including it's dependencies such as ResolutionSession).
38-
* Another issue was that if a Mixin that is type constrained is used with another Mixin
39-
* that is not, it will result in an error.
40-
* Example (class MyApp extends BootMixin(RepositoryMixin(Application))) {};
41-
********************* END OF NOTE ********************
4235
*/
43-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
44-
export function BootMixin<T extends Constructor<any>>(superClass: T) {
36+
export function BootMixin<T extends MixinTarget<Application>>(superClass: T) {
4537
return class extends superClass implements Bootable {
4638
projectRoot: string;
4739
bootOptions?: BootOptions;
@@ -57,26 +49,42 @@ export function BootMixin<T extends Constructor<any>>(superClass: T) {
5749
() => this.projectRoot,
5850
);
5951
this.bind(BootBindings.BOOT_OPTIONS).toDynamicValue(
60-
() => this.bootOptions,
52+
() => this.bootOptions ?? {},
6153
);
6254
}
6355

6456
/**
6557
* Convenience method to call bootstrapper.boot() by resolving bootstrapper
6658
*/
6759
async boot(): Promise<void> {
68-
if (this.state === 'booting') return this.awaitState('booted');
69-
this.assertNotInProcess('boot');
70-
this.assertInStates('boot', 'created', 'booted');
60+
/* eslint-disable @typescript-eslint/ban-ts-ignore */
61+
// A workaround to access protected Application methods
62+
const self = (this as unknown) as Application;
63+
64+
if (this.state === 'booting') {
65+
// @ts-ignore
66+
return self.awaitState('booted');
67+
}
68+
// @ts-ignore
69+
self.assertNotInProcess('boot');
70+
// @ts-ignore
71+
self.assertInStates('boot', 'created', 'booted');
72+
7173
if (this.state === 'booted') return;
72-
this.setState('booting');
74+
// @ts-ignore
75+
self.setState('booting');
76+
7377
// Get a instance of the BootStrapper
7478
const bootstrapper: Bootstrapper = await this.get(
7579
BootBindings.BOOTSTRAPPER_KEY,
7680
);
7781

7882
await bootstrapper.boot();
83+
84+
// @ts-ignore
7985
this.setState('booted');
86+
87+
/* eslint-enable @typescript-eslint/ban-ts-ignore */
8088
}
8189

8290
/**
@@ -115,9 +123,17 @@ export function BootMixin<T extends Constructor<any>>(superClass: T) {
115123
* app.component(ProductComponent);
116124
* ```
117125
*/
118-
public component(component: Constructor<{}>) {
119-
super.component(component);
120-
this.mountComponentBooters(component);
126+
// Unfortunately, TypeScript does not allow overriding methods inherited
127+
// from mapped types. https://github.com/microsoft/TypeScript/issues/38496
128+
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
129+
// @ts-ignore
130+
public component<C extends Component = Component>(
131+
componentCtor: Constructor<C>,
132+
nameOrOptions?: string | BindingFromClassOptions,
133+
) {
134+
const binding = super.component(componentCtor, nameOrOptions);
135+
this.mountComponentBooters(componentCtor);
136+
return binding;
121137
}
122138

123139
/**
@@ -129,7 +145,9 @@ export function BootMixin<T extends Constructor<any>>(superClass: T) {
129145
*/
130146
mountComponentBooters(component: Constructor<{}>) {
131147
const componentKey = `components.${component.name}`;
132-
const compInstance = this.getSync(componentKey);
148+
const compInstance = this.getSync<{
149+
booters?: Constructor<Booter>[];
150+
}>(componentKey);
133151

134152
if (compInstance.booters) {
135153
this.booters(...compInstance.booters);

packages/core/src/core.types.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright IBM Corp. 2017,2020. All Rights Reserved.
2+
// Node module: @loopback/core
3+
// This file is licensed under the MIT License.
4+
// License text available at https://opensource.org/licenses/MIT
5+
6+
import {Constructor} from '@loopback/context';
7+
8+
/**
9+
* A replacement for `typeof Target` to be used in mixin class definitions.
10+
* This is a workaround for TypeScript limitation described in
11+
* - https://github.com/microsoft/TypeScript/issues/17293
12+
* - https://github.com/microsoft/TypeScript/issues/17744
13+
* - https://github.com/microsoft/TypeScript/issues/36060
14+
*
15+
* @example
16+
*
17+
* ```ts
18+
* export function MyMixin<T extends MixinTarget<Application>>(superClass: T) {
19+
* return class extends superClass {
20+
* // contribute new class members
21+
* }
22+
* };
23+
* ```
24+
*
25+
* TypeScript does not allow class mixins to access protected members from
26+
* the base class. You can use the following approach as a workaround:
27+
*
28+
* ```ts
29+
* // @ts-ignore
30+
* (this as unknown as {YourBaseClass}).protectedMember
31+
* ```
32+
*
33+
* The directive `@ts-ignore` suppresses compiler error about accessing
34+
* a protected member from outside. Unfortunately, it also disables other
35+
* compile-time checks (e.g. to verify that a protected method was invoked
36+
* with correct arguments, and so on). This is the same behavior you
37+
* would get by using `Constructor<any>` instead of `MixinTarget<Application>`.
38+
* The major improvement is that TypeScript can still infer the return
39+
* type of the protected member, therefore `any` is NOT introduced to subsequent
40+
* code.
41+
*
42+
* TypeScript also does not allow mixin class to overwrite a method inherited
43+
* from a mapped type, see https://github.com/microsoft/TypeScript/issues/38496
44+
* As a workaround, use `@ts-ignore` to disable the error.
45+
*
46+
* ```ts
47+
* export function RepositoryMixin<T extends MixinTarget<Application>>(
48+
* superClass: T,
49+
* ) {
50+
* return class extends superClass {
51+
* // @ts-ignore
52+
* public component<C extends Component = Component>(
53+
* componentCtor: Constructor<C>,
54+
* nameOrOptions?: string | BindingFromClassOptions,
55+
* ) {
56+
* const binding = super.component(componentCtor, nameOrOptions);
57+
* // ...
58+
* return binding;
59+
* }
60+
* }
61+
* ```
62+
*/
63+
export type MixinTarget<T extends object> = Constructor<
64+
{
65+
// Enumerate only public members to avoid the following compiler error:
66+
// Property '(name)' of exported class expression
67+
// may not be private or protected.ts(4094)
68+
[p in keyof T]: T[p];
69+
}
70+
>;

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export * from '@loopback/context';
2020
// Export APIs
2121
export * from './application';
2222
export * from './component';
23+
export * from './core.types';
2324
export * from './extension-point';
2425
export * from './keys';
2526
export * from './lifecycle';

packages/express/src/middleware-registry.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,5 +88,4 @@ export interface MiddlewareRegistry {
8888
/**
8989
* Base Context that provides APIs to register middleware
9090
*/
91-
export abstract class BaseMiddlewareRegistry extends MiddlewareMixin(Context)
92-
implements MiddlewareRegistry {}
91+
export abstract class BaseMiddlewareRegistry extends MiddlewareMixin(Context) {}

packages/express/src/mixins/middleware.mixin.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
Constructor,
1010
Context,
1111
isBindingAddress,
12+
MixinTarget,
1213
Provider,
1314
} from '@loopback/core';
1415
import {
@@ -36,8 +37,7 @@ function extendsFrom(
3637
return false;
3738
}
3839

39-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
40-
export function MiddlewareMixin<T extends Constructor<any>>(superClass: T) {
40+
export function MiddlewareMixin<T extends MixinTarget<Context>>(superClass: T) {
4141
if (!extendsFrom(superClass, Context)) {
4242
throw new TypeError('The super class does not extend from Context');
4343
}

packages/repository/src/__tests__/fixtures/mixins/category-property-mixin.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// This file is licensed under the MIT License.
44
// License text available at https://opensource.org/licenses/MIT
55

6-
import {Constructor} from '@loopback/context';
6+
import {MixinTarget} from '@loopback/core';
77
import {Model, property} from '../../..';
88

99
/**
@@ -12,7 +12,7 @@ import {Model, property} from '../../..';
1212
* @param superClass - Base Class
1313
* @typeParam T - Model class
1414
*/
15-
export function AddCategoryPropertyMixin<T extends Constructor<Model>>(
15+
export function AddCategoryPropertyMixin<T extends MixinTarget<Model>>(
1616
superClass: T,
1717
) {
1818
class MixedModel extends superClass {

packages/repository/src/__tests__/fixtures/mixins/find-by-title-repo-mixin.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// This file is licensed under the MIT License.
44
// License text available at https://opensource.org/licenses/MIT
55

6-
import {Constructor} from '@loopback/context';
6+
import {MixinTarget} from '@loopback/core';
77
import {CrudRepository, Model, Where} from '../../..';
88

99
/**
@@ -24,7 +24,7 @@ export interface FindByTitle<M extends Model> {
2424
*/
2525
export function FindByTitleRepositoryMixin<
2626
M extends Model & {title: string},
27-
R extends Constructor<CrudRepository<M>>
27+
R extends MixinTarget<CrudRepository<M>>
2828
>(superClass: R) {
2929
class MixedRepository extends superClass implements FindByTitle<M> {
3030
async findByTitle(title: string): Promise<M[]> {

0 commit comments

Comments
 (0)