Skip to content

Commit 1e90976

Browse files
authored
Merge pull request #71 from cloudnc/feat/ng-on-changes-type-safety
feat: add ngOnChanges type safety for inputs
2 parents 5409e23 + 6da92b7 commit 1e90976

File tree

3 files changed

+75
-16
lines changed

3 files changed

+75
-16
lines changed

README.md

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,8 @@ import { getObservableLifecycle } from 'ngx-observable-lifecycle';
9494
changeDetection: ChangeDetectionStrategy.OnPush,
9595
})
9696
export class ChildComponent {
97-
@Input() input: number | undefined | null;
97+
@Input() input1: number | undefined | null;
98+
@Input() input2: string | undefined | null;
9899
99100
constructor() {
100101
const {
@@ -106,16 +107,32 @@ export class ChildComponent {
106107
ngAfterViewInit,
107108
ngAfterViewChecked,
108109
ngOnDestroy,
109-
} = getObservableLifecycle(this);
110+
} =
111+
// specifying the generics is only needed if you intend to
112+
// use the `ngOnChanges` observable, this way you'll have
113+
// typed input values instead of just a `SimpleChange`
114+
getObservableLifecycle<ChildComponent, 'input1' | 'input2'>(this);
110115
111-
ngOnChanges.subscribe(() => console.count('onChanges'));
112116
ngOnInit.subscribe(() => console.count('onInit'));
113117
ngDoCheck.subscribe(() => console.count('doCheck'));
114118
ngAfterContentInit.subscribe(() => console.count('afterContentInit'));
115119
ngAfterContentChecked.subscribe(() => console.count('afterContentChecked'));
116120
ngAfterViewInit.subscribe(() => console.count('afterViewInit'));
117121
ngAfterViewChecked.subscribe(() => console.count('afterViewChecked'));
118122
ngOnDestroy.subscribe(() => console.count('onDestroy'));
123+
124+
ngOnChanges.subscribe(changes => {
125+
console.count('onChanges');
126+
127+
// do note that we have a type safe object here for `changes`
128+
// with the inputs from our component and their associated values typed accordingly
129+
130+
changes.input1?.currentValue; // `number | null | undefined`
131+
changes.input1?.previousValue; // `number | null | undefined`
132+
133+
changes.input2?.currentValue; // `string | null | undefined`
134+
changes.input2?.previousValue; // `string | null | undefined`
135+
});
119136
}
120137
}
121138

projects/ngx-observable-lifecycle/src/lib/ngx-observable-lifecycle.ts

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,24 +26,47 @@ export type LifecycleHookKey = keyof AllHooks;
2626
type AllHookOptions = Record<LifecycleHookKey, true>;
2727
type DecorateHookOptions = Partial<AllHookOptions>;
2828

29+
export interface TypedSimpleChange<Data> {
30+
previousValue: Data;
31+
currentValue: Data;
32+
firstChange: boolean;
33+
}
34+
35+
/**
36+
* FIRST POINT:
37+
* the key is made optional because an ngOnChanges will only give keys of inputs that have changed
38+
* SECOND POINT:
39+
* the value is associated with `| null` as if an input value is defined but actually retrieved with
40+
* an `async` pipe, we'll initially get a `null` value
41+
*
42+
* For both point, feel free to check the following stackblitz that demo this
43+
* https://stackblitz.com/edit/stackblitz-starters-s5uphw?file=src%2Fmain.ts
44+
*/
45+
export type TypedSimpleChanges<Component, Keys extends keyof Component> = {
46+
[Key in Keys]?: TypedSimpleChange<Component[Key]> | null;
47+
};
48+
2949
// none of the hooks have arguments, EXCEPT ngOnChanges which we need to handle differently
30-
export type DecoratedHooks = Record<Exclude<LifecycleHookKey, 'ngOnChanges'>, Observable<void>> & {
31-
ngOnChanges: Observable<Parameters<OnChanges['ngOnChanges']>[0]>;
50+
export type DecoratedHooks<Component = any, Keys extends keyof Component = any> = Record<
51+
Exclude<LifecycleHookKey, 'ngOnChanges'>,
52+
Observable<void>
53+
> & {
54+
ngOnChanges: Observable<TypedSimpleChanges<Component, Keys>>;
3255
};
3356
export type DecoratedHooksSub = {
3457
[k in keyof DecoratedHooks]: DecoratedHooks[k] extends Observable<infer U> ? Subject<U> : never;
3558
};
3659

37-
type PatchedComponentInstance<K extends LifecycleHookKey> = Pick<AllHooks, K> & {
38-
[hookSubject]: Pick<DecoratedHooksSub, K>;
60+
type PatchedComponentInstance<Hooks extends LifecycleHookKey = any> = Pick<AllHooks, Hooks> & {
61+
[hookSubject]: Pick<DecoratedHooksSub, Hooks>;
3962
constructor: {
4063
prototype: {
41-
[hooksPatched]: Pick<DecorateHookOptions, K>;
64+
[hooksPatched]: Pick<DecorateHookOptions, Hooks>;
4265
};
4366
};
4467
};
4568

46-
function getSubjectForHook(componentInstance: PatchedComponentInstance<any>, hook: LifecycleHookKey): Subject<void> {
69+
function getSubjectForHook(componentInstance: PatchedComponentInstance, hook: LifecycleHookKey): Subject<void> {
4770
if (!componentInstance[hookSubject]) {
4871
componentInstance[hookSubject] = {};
4972
}
@@ -87,10 +110,12 @@ function getSubjectForHook(componentInstance: PatchedComponentInstance<any>, hoo
87110
/**
88111
* Library authors should use this to create their own lifecycle-aware functionality
89112
*/
90-
export function getObservableLifecycle(classInstance: any): DecoratedHooks {
91-
return new Proxy({} as DecoratedHooks, {
92-
get(target: DecoratedHooks, p: LifecycleHookKey): Observable<void> {
93-
return getSubjectForHook(classInstance, p).asObservable();
113+
export function getObservableLifecycle<Component, Inputs extends keyof Component = never>(
114+
classInstance: Component,
115+
): DecoratedHooks<Component, Inputs> {
116+
return new Proxy({} as DecoratedHooks<Component, Inputs>, {
117+
get(target: DecoratedHooks<Component, Inputs>, p: LifecycleHookKey): Observable<void> {
118+
return getSubjectForHook(classInstance as unknown as PatchedComponentInstance, p).asObservable();
94119
},
95120
});
96121
}

src/app/child/child.component.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import { getObservableLifecycle } from 'ngx-observable-lifecycle';
77
changeDetection: ChangeDetectionStrategy.OnPush,
88
})
99
export class ChildComponent {
10-
@Input() input: number | undefined | null;
10+
@Input() input1: number | undefined | null;
11+
@Input() input2: string | undefined | null;
1112

1213
constructor() {
1314
const {
@@ -19,15 +20,31 @@ export class ChildComponent {
1920
ngAfterViewInit,
2021
ngAfterViewChecked,
2122
ngOnDestroy,
22-
} = getObservableLifecycle(this);
23+
} =
24+
// specifying the generics is only needed if you intend to
25+
// use the `ngOnChanges` observable, this way you'll have
26+
// typed input values instead of just a `SimpleChange`
27+
getObservableLifecycle<ChildComponent, 'input1' | 'input2'>(this);
2328

24-
ngOnChanges.subscribe(() => console.count('onChanges'));
2529
ngOnInit.subscribe(() => console.count('onInit'));
2630
ngDoCheck.subscribe(() => console.count('doCheck'));
2731
ngAfterContentInit.subscribe(() => console.count('afterContentInit'));
2832
ngAfterContentChecked.subscribe(() => console.count('afterContentChecked'));
2933
ngAfterViewInit.subscribe(() => console.count('afterViewInit'));
3034
ngAfterViewChecked.subscribe(() => console.count('afterViewChecked'));
3135
ngOnDestroy.subscribe(() => console.count('onDestroy'));
36+
37+
ngOnChanges.subscribe(changes => {
38+
console.count('onChanges');
39+
40+
// do note that we have a type safe object here for `changes`
41+
// with the inputs from our component and their associated values typed accordingly
42+
43+
changes.input1?.currentValue; // `number | null | undefined`
44+
changes.input1?.previousValue; // `number | null | undefined`
45+
46+
changes.input2?.currentValue; // `string | null | undefined`
47+
changes.input2?.previousValue; // `string | null | undefined`
48+
});
3249
}
3350
}

0 commit comments

Comments
 (0)