-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
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
feat(signals): Protection for Overriding Properties #4199
Draft
rainerhahnekamp
wants to merge
1
commit into
ngrx:main
Choose a base branch
from
rainerhahnekamp:feat/overriding-protection
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,318 @@ | ||
import { computed, Signal, Type } from '@angular/core'; | ||
import { | ||
EmptyFeatureResult, | ||
MergeFeatureResults, | ||
SignalStoreConfig, | ||
SignalStoreFeature, | ||
SignalStoreFeatureResult, | ||
SignalStoreProps, | ||
} from './signal-store-models'; | ||
import { StateSignal } from './state-signal'; | ||
import { withState } from './with-state'; | ||
import { withComputed } from './with-computed'; | ||
import { withMethods } from '@ngrx/signals'; | ||
import { Prettify } from './ts-helpers'; | ||
|
||
/** | ||
* This Proof of Concept would introduce a feature which prevents | ||
* that feature override properties (`state`, `signals`, `methods`). | ||
* | ||
* It does that by adding a conditional type `NoOverride` which | ||
* as a constraint to the parameters of `signalState`. | ||
* | ||
* Example: | ||
* signalState( | ||
* withState({ id: 1, name: 'hallo', prettyName: 'hi' }), | ||
* withComputed((store) => { | ||
* return { | ||
* prettyName: computed(() => store.name()), | ||
* }; | ||
* }) | ||
* ) | ||
* | ||
* This would fail to compile because `prettyName` is overriden. | ||
* | ||
* The overriding protection is enabled by default. It is very likely | ||
* that overriding doesn't happen on purpose. | ||
* | ||
* An opt-out is also possible via `signalState({allowOverrides: true})`. | ||
*/ | ||
|
||
type OverridenProperties<T extends string> = `overriding property: '${T}'`; | ||
|
||
export type NestedProperties<Type> = { | ||
[Property in keyof Type]: keyof Type[Property]; | ||
} extends Record<string, infer P> | ||
? `${string & P}` | ||
: never; | ||
|
||
export type SameProperties<Store, Extension> = Extract< | ||
NestedProperties<Store>, | ||
NestedProperties<Extension> | ||
>; | ||
|
||
export type NoOverride< | ||
Store extends SignalStoreFeatureResult, | ||
Feature extends SignalStoreFeatureResult | ||
> = SameProperties<Store, Feature> extends never | ||
? Extract<Store, Feature> extends never | ||
? never | ||
: SameProperties<Store, Feature> | ||
: SameProperties<Store, Feature>; | ||
|
||
type Merge<Features extends SignalStoreFeatureResult[]> = Features extends [ | ||
infer Feature1 extends SignalStoreFeatureResult, | ||
infer Feature2 extends SignalStoreFeatureResult | ||
] | ||
? NoOverride<Feature1, Feature2> extends never | ||
? SignalStoreFeature<Feature1, Feature2> | ||
: SignalStoreFeature< | ||
Feature1, | ||
EmptyFeatureResult & { | ||
state: { | ||
override: () => OverridenProperties<NoOverride<Feature1, Feature2>>; | ||
}; | ||
} | ||
> | ||
: Features extends [ | ||
infer Feature1 extends SignalStoreFeatureResult, | ||
infer Feature2 extends SignalStoreFeatureResult, | ||
...infer RestFeatures extends SignalStoreFeatureResult[] | ||
] | ||
? NoOverride<Feature1, Feature2> extends never | ||
? Merge<[MergeFeatureResults<[Feature1, Feature2]>, ...RestFeatures]> | ||
: never | ||
: never; | ||
|
||
declare function signalStore< | ||
F1 extends SignalStoreFeatureResult, | ||
F2 extends SignalStoreFeatureResult, | ||
R extends SignalStoreFeatureResult = MergeFeatureResults<[F1, F2]> | ||
>( | ||
f1: SignalStoreFeature<EmptyFeatureResult, F1>, | ||
f2: Merge<[{} & F1, F2]> | ||
): Type<SignalStoreProps<R> & StateSignal<R['state']>>; | ||
|
||
declare function signalStore< | ||
F1 extends SignalStoreFeatureResult, | ||
F2 extends SignalStoreFeatureResult, | ||
F3 extends SignalStoreFeatureResult, | ||
R extends SignalStoreFeatureResult = MergeFeatureResults<[F1, F2, F3]> | ||
>( | ||
f1: SignalStoreFeature<EmptyFeatureResult, F1>, | ||
f2: Merge<[{} & F1, F2]>, | ||
f3: Merge<[F1, F2, F3]> | ||
): Type<SignalStoreProps<R> & StateSignal<R['state']>>; | ||
|
||
declare function signalStore< | ||
F1 extends SignalStoreFeatureResult, | ||
F2 extends SignalStoreFeatureResult, | ||
F3 extends SignalStoreFeatureResult, | ||
R extends SignalStoreFeatureResult = MergeFeatureResults<[F1, F2, F3]> | ||
>( | ||
config: { allowOverrides: true } & SignalStoreConfig, | ||
f1: SignalStoreFeature<EmptyFeatureResult, F1>, | ||
f2: SignalStoreFeature<{} & F1, F2>, | ||
f3: SignalStoreFeature<MergeFeatureResults<[F1, F2]>, F3> | ||
): Type<SignalStoreProps<R> & StateSignal<Prettify<R['state']>>>; | ||
|
||
type Equals<A, B> = A extends B ? (B extends A ? true : false) : false; | ||
type Assert<T extends true> = T; | ||
|
||
describe('store with 2 features', () => { | ||
describe('overrides should fail to compile', () => { | ||
test('state and computed', () => { | ||
const Store = signalStore( | ||
withState({ id: 1, name: 'hallo', other: 'hi' }), | ||
// @ts-expect-error other | ||
withComputed((store) => { | ||
return { other: computed(() => store.name()) }; | ||
}) | ||
); | ||
}); | ||
|
||
test('2 states', () => { | ||
signalStore( | ||
withState({ id: 1, name: 'hallo', other: 'hi' }), | ||
// @ts-expect-error id | other | ||
withState({ id: 2, other: 'not allowed' }) | ||
); | ||
}); | ||
|
||
test('state and method', () => { | ||
const Overriding1c = signalStore( | ||
withState({ id: 1, name: 'hallo', prettyName: 'hi' }), | ||
// @ts-expect-error other | ||
withMethods((store) => { | ||
return { | ||
prettyName() { | ||
`${store.id()}: ${store.name()}`; | ||
}, | ||
}; | ||
}) | ||
); | ||
}); | ||
}); | ||
|
||
describe('no overrides', () => { | ||
test('state and computed', () => { | ||
const NonOverriding1a = signalStore( | ||
withState({ id: 1, name: 'hallo', other: 'hi' }), | ||
withComputed((store) => { | ||
return { | ||
prettyName: computed(() => store.name()), | ||
}; | ||
}) | ||
); | ||
|
||
const store = new NonOverriding1a(); | ||
type A1 = Assert<Equals<typeof store.id, Signal<number>>>; | ||
type A2 = Assert<Equals<typeof store.name, Signal<string>>>; | ||
type A3 = Assert<Equals<typeof store.prettyName, Signal<string>>>; | ||
}); | ||
}); | ||
}); | ||
|
||
describe('store with 3 features', () => { | ||
describe('overrides should fail to compile', () => { | ||
test('state, computed and methods', () => { | ||
signalStore( | ||
withState({ id: 1, name: 'hallo', other: 'hi' }), | ||
withComputed((store) => { | ||
return { | ||
prettyName: computed(() => store.name()), | ||
}; | ||
}), | ||
// @ts-expect-error prettyName | ||
withMethods((store) => { | ||
return { | ||
prettyName() { | ||
`${store.id()}: ${store.name()}`; | ||
}, | ||
}; | ||
}) | ||
); | ||
}); | ||
|
||
test('three states', () => { | ||
signalStore( | ||
withState({ id: 1, name: 'hallo', other: 'hi' }), | ||
withState({ key: '1' }), | ||
// @ts-expect-error overrides other | ||
withState({ other: '1' }) | ||
); | ||
}); | ||
|
||
test('state, methods and computed', () => { | ||
signalStore( | ||
withState({ id: 1, name: 'hallo', other: 'hi' }), | ||
withMethods((store) => { | ||
return { | ||
prettyName() { | ||
`${store.id()}: ${store.name()}`; | ||
}, | ||
}; | ||
}), | ||
// @ts-expect-error overrides prettyName | ||
withComputed((store) => { | ||
store; | ||
return { | ||
prettyName: computed(() => store.name()), | ||
}; | ||
}) | ||
); | ||
}); | ||
}); | ||
|
||
describe('no overrides', () => { | ||
test('state, computed, methods', () => { | ||
const Store = signalStore( | ||
withState({ id: 1, name: 'hallo', other: 'hi' }), | ||
withComputed((store) => { | ||
return { | ||
prettyName: computed(() => store.name()), | ||
}; | ||
}), | ||
withMethods((store) => { | ||
return { | ||
log() { | ||
console.log(store.prettyName()); | ||
}, | ||
}; | ||
}) | ||
); | ||
|
||
const store = new Store(); | ||
|
||
type A1 = Assert<Equals<typeof store.id, Signal<number>>>; | ||
type A2 = Assert<Equals<typeof store.name, Signal<string>>>; | ||
type A3 = Assert<Equals<typeof store.prettyName, Signal<string>>>; | ||
type A4 = Assert<Equals<typeof store.log, () => void>>; | ||
}); | ||
|
||
test('triple state', () => { | ||
const Store = signalStore( | ||
withState({ id: 1, name: 'hallo' }), | ||
withState({ key: '1' }), | ||
withState({ entities: [1] }) | ||
); | ||
|
||
const store = new Store(); | ||
|
||
type A1 = Assert<Equals<typeof store.id, Signal<number>>>; | ||
type A2 = Assert<Equals<typeof store.name, Signal<string>>>; | ||
type A3 = Assert<Equals<typeof store.key, Signal<string>>>; | ||
type A4 = Assert<Equals<typeof store.entities, Signal<number[]>>>; | ||
}); | ||
}); | ||
}); | ||
|
||
describe('overrides should work if enabled in config', () => { | ||
test('state, computed and methods', () => { | ||
signalStore( | ||
{ allowOverrides: true, providedIn: 'root' }, | ||
withState({ id: 1, name: 'hallo', other: 'hi' }), | ||
withComputed((store) => { | ||
return { | ||
prettyName: computed(() => store.name()), | ||
}; | ||
}), | ||
withMethods((store) => { | ||
return { | ||
prettyName() { | ||
`${store.id()}: ${store.name()}`; | ||
}, | ||
}; | ||
}) | ||
); | ||
}); | ||
|
||
test('three states', () => { | ||
signalStore( | ||
{ allowOverrides: true, providedIn: 'root' }, | ||
withState({ id: 1, name: 'hallo', other: 'hi' }), | ||
withState({ key: '1' }), | ||
withState({ other: '1' }) | ||
); | ||
}); | ||
|
||
test('state, methods and computed', () => { | ||
signalStore( | ||
{ allowOverrides: true, providedIn: 'root' }, | ||
withState({ id: 1, name: 'hallo', other: 'hi' }), | ||
withMethods((store) => { | ||
return { | ||
prettyName() { | ||
`${store.id()}: ${store.name()}`; | ||
}, | ||
}; | ||
}), | ||
withComputed((store) => { | ||
store; | ||
return { | ||
prettyName: computed(() => store.name()), | ||
}; | ||
}) | ||
); | ||
}); | ||
}); |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
First of all thanks for all these PRs and feedback @rainerhahnekamp.
If we go through with this change, what would be the benefit of having this configurable?
Is there a use case where this would be desired to turn this off, and override properties?
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.
Hi Tim, thanks a lot as well.
To me, the use case for disabling the override protection would be in enterprise-like environments, where teams share huge extensions and I - as a consumer - want to customize or change parts of them. So a little bit similar to what we can do with class inheritance.
For example, I'd like to override
undo
of awithUndoRedo
extension:Maybe it doesn't have to be a configuration value but it could be a function we can add selectively:
Before we add new features to https://github.com/angular-architects/ngrx-toolkit, I'd like to post them here first.
If you see it as something which should be part of the core, we can move forward here. If you see, it is a better fit for a community contribution, we would integrate it into the toolkit. Could also serve as an incubator.
In the same manner, I also plan to push the branch for an encapsulation feature.
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.
Thanks for the elaboration @rainerhahnekamp