RFC: Improvement of Typings for NGXS State Operators #1805
-
Background Problem Context type StateOperator<T> = (existing: Readonly<T>) => T;
interface StateContext<T> {
setState(val: T | StateOperator<T>): T;
} and the following app specific code: interface MyModel {
foo: number;
bar: boolean;
baz?: string;
}
const ctx: StateContext<MyModel>; // from the first argument of an @Action function We can perform state updates in the following way: ctx.setState({ foo: 1, bar:'123' });
ctx.setState((state) => ({...state, foo: 234})); All of the above mechanisms work well. One important requirement to note here is that when you delete the We can also perform a state update with the // This works well:
ctx.setState(patch({ foo: 234 }));
// This gives a type error if you provide an invalid property:
ctx.setState(patch({ shjfadklsja: 3 }));
// BUT this does not give an error on the invalid prop of you provide at least one valid prop:
ctx.setState(patch({ foo: 234, shjfadklsja: 3 })); There is also no type completion within the patch object!
Also note: one of the features of State Operators is that you can roll your own operator with a function that returns a state operator. function customOperator<T>(partial: Partial<T>): StateOperator<T> {
return (state) => ({ ...state, ...partial })
}
// This usage works:
ctx.setState(customOperator<MyModel>({ foo: 234 }));
// But this fails the typing if the type T is left out:
ctx.setState(customOperator({ foo: 234 })); This is not ideal. Potentially we need to provide a helper for people creating their own operators to make this cleaner and still keep the type safety of the initial implementation. All of the above is seeding this discussion... Playground |
Beta Was this translation helpful? Give feedback.
Replies: 3 comments 13 replies
-
Following the discussion we had, I've been investigating this and I've come to some conclusions, not being able to tell why this happens though. First, this happens because TypeScript is inferring the type type StateOperator<T> = (args: Readonly<T>) => T
function someOperator<T>(args: Partial<T>): StateOperator<T> {
....
}
interface Model {
a: number
b: number
}
const operator: StateOperator<Model> = someOperator({ a: 1 })
/* It infers the parameter type and the result of the function call as
StateOperator<{ a: number }>, which makes auto completion not
available for the parameter value and it also doesn't typecheck
because { a: number } is not a subtype of Model because it misses
the b property. */ Interesting though, is that Using a different approach makes it typecheck, but with no auto completion working. Which is the reason why some of the operators work, but without auto completion. function someOperator<A, T extends A>(args: Partial<A>): StateOperator<T> {
...
}
/* This works because Model is a subtype of { a: number }, which makes the condition
T extends A being true. */ |
Beta Was this translation helpful? Give feedback.
-
For reference, these are my current attempts. I have included this as a starter playground in the discussion description: interface Model {
foo: number
bar: string
}
type StateOperator<T> = (state: Readonly<T>) => T
function setState<T>(operator: T | StateOperator<T>): void { }
function old_code_advanced_recursive_patching() { // WORKED FOR TS2.8 - but fails since TS3.1
type PatchSpec<T> = { [P in keyof T]?: T[P] | StateOperator<NonNullable<T[P]>> };
type PatchValues<T> = {
readonly [P in keyof T]?: T[P] extends (...args: any[]) => infer R ? R : T[P];
};
function customOperator<T>(patchObject: PatchSpec<T>) {
const patchValue: Partial<T> = patchObject as any; // Implementation not included
return (state: T) => ({ ...state, ...patchObject });
}
/* IMPLICIT TYPES */
// Noraml usage
setState<Model>(customOperator({ foo: 1 })) // ## FAILED! ## - BROKEN since TS3.1
// Should fail unknown prop
setState<Model>(customOperator({ foo: 1, asdf: '' })) // PASS
// Should fail all props unknown
setState<Model>(customOperator({ asdf: '' })) // PASS
// Test Intellisense - place cursor between the braces and try autocomplete
setState<Model>(customOperator({ })) // PASS
// Test Intellisense - place cursor between the braces as next prop after foo and try autocomplete
setState<Model>(customOperator({ foo: 1, })) // ## FAILED! ## NO INTELLISENSE - BROKEN since TS3.1
/* EXPLICIT TYPES */
// Normal usage
setState<Model>(customOperator<Model>({ foo: 1 })) // PASS
// Should fail unknown prop
setState<Model>(customOperator<Model>({ foo: 1, asdf: '' })) // PASS
// Should fail all props unknown
setState<Model>(customOperator<Model>({ asdf: '' })) // PASS
// Test Intellisense - place cursor between the braces and try autocomplete
setState<Model>(customOperator<Model>({})) // PASS
// Test Intellisense - place cursor between the braces as next prop after foo and try autocomplete
setState<Model>(customOperator<Model>({ foo: 1, })) // PASS
}
function current_code_advanced_recursive_patching() {
type PatchSpec<T> = { [P in keyof T]?: T[P] | StateOperator<NonNullable<T[P]>> };
type PatchValues<T> = {
readonly [P in keyof T]?: T[P] extends (...args: any[]) => infer R ? R : T[P];
};
type PatchOperator<T> = <U extends PatchValues<T>>(existing: Readonly<U>) => U;
function customOperator<T>(patchObject: PatchSpec<T>): PatchOperator<T> {
const patchValue: Partial<T> = patchObject as any; // Implementation not included
return ((state: T) => ({ ...state, ...patchValue })) as unknown as PatchOperator<T>;
}
/* IMPLICIT TYPES */
// Noraml usage
setState<Model>(customOperator({ foo: 1 })) // PASS
// Should fail unknown prop
setState<Model>(customOperator({ foo: 1, asdf: '' })) // ## FAILED! ## - Allows invalid prop! [CRITICAL BUG!]
// Should fail all props unknown
setState<Model>(customOperator({ asdf: '' })) // PASS - Type error shown
// Test Intellisense - place cursor between the braces and try autocomplete
setState<Model>(customOperator({})) // PASS
// Test Intellisense - place cursor between the braces as next prop after foo and try autocomplete
setState<Model>(customOperator({ foo: 1, })) // ## FAILED! ## NO INTELLISENSE
/* EXPLICIT TYPES */
// Normal usage
setState<Model>(customOperator<Model>({ foo: 1 })) // PASS
// Should fail unknown prop
setState<Model>(customOperator<Model>({ foo: 1, asdf: '' })) // PASS - Type error shown
// Should fail all props unknown
setState<Model>(customOperator<Model>({ asdf: '' })) // PASS - Type error shown
// Test Intellisense - place cursor between the braces and try autocomplete
setState<Model>(customOperator<Model>({})) // PASS
// Test Intellisense - place cursor between the braces as next prop after foo and try autocomplete
setState<Model>(customOperator<Model>({ foo: 1, })) // PASS
}
function current_code_simplified_shallow_patch_variant() {
type PatchOperator<T> = <U extends T>(existing: Readonly<U>) => U;
function customOperator<T>(partial: Partial<T>): PatchOperator<T> {
return (state) => ({ ...state, ...partial })
}
/* IMPLICIT TYPES */
// Noraml usage
setState<Model>(customOperator({ foo: 1 })) // PASS
// Should fail unknown prop
setState<Model>(customOperator({ foo: 1, asdf: '' })) // PASS - Type error shown
// Should fail all props unknown
setState<Model>(customOperator({ asdf: '' })) // PASS - Type error shown
// Test Intellisense - place cursor between the braces and try autocomplete
setState<Model>(customOperator({ })) // ## FAILED! ## NO INTELLISENSE
// Test Intellisense - place cursor between the braces as next prop after foo and try autocomplete
setState<Model>(customOperator({ foo: 1, })) // ## FAILED! ## NO INTELLISENSE
/* EXPLICIT TYPES */
// Normal usage
setState<Model>(customOperator<Model>({ foo: 1 })) // PASS
// Should fail unknown prop
setState<Model>(customOperator<Model>({ foo: 1, asdf: '' })) // PASS - Type error shown
// Should fail all props unknown
setState<Model>(customOperator<Model>({ asdf: '' })) // PASS - Type error shown
// Test Intellisense - place cursor between the braces and try autocomplete
setState<Model>(customOperator<Model>({})) // PASS
// Test Intellisense - place cursor between the braces as next prop after foo and try autocomplete
setState<Model>(customOperator<Model>({ foo: 1, })) // PASS
}
function simpified_shallow_patch_attempt1() {
function customOperator<U, T extends U = U>(partial: Partial<U>): StateOperator<T> {
return (state) => ({ ...state, ...partial })
}
/* IMPLICIT TYPES */
// Noraml usage
setState<Model>(customOperator({ foo: 1 })) // PASS
// Should fail unknown prop
setState<Model>(customOperator({ foo: 1, asdf: '' })) // PASS - Type error shown
// Should fail all props unknown
setState<Model>(customOperator({ asdf: '' })) // PASS - Type error shown
// Test Intellisense - place cursor between the braces and try autocomplete
setState<Model>(customOperator({ })) // ## FAILED! ## NO INTELLISENSE
// Test Intellisense - place cursor between the braces as next prop after foo and try autocomplete
setState<Model>(customOperator({ foo: 1, })) // ## FAILED! ## NO INTELLISENSE
/* EXPLICIT TYPES */
// Normal usage
setState<Model>(customOperator<Model>({ foo: 1 })) // PASS
// Should fail unknown prop
setState<Model>(customOperator<Model>({ foo: 1, asdf: '' })) // PASS - Type error shown
// Should fail all props unknown
setState<Model>(customOperator<Model>({ asdf: '' })) // PASS - Type error shown
// Test Intellisense - place cursor between the braces and try autocomplete
setState<Model>(customOperator<Model>({})) // PASS
// Test Intellisense - place cursor between the braces as next prop after foo and try autocomplete
setState<Model>(customOperator<Model>({ foo: 1, })) // PASS
}
function simpified_shallow_patch_attempt2() {
function customOperator<T>(partial: Partial<T>): StateOperator<T> {
return (state) => ({ ...state, ...partial })
}
/* IMPLICIT TYPES */
// Noraml usage
setState<Model>(customOperator({ foo: 1 })) // ## FAILED! ##
// Should fail unknown prop
setState<Model>(customOperator({ foo: 1, asdf: '' })) // PASS - Type error shown
// Should fail all props unknown
setState<Model>(customOperator({ asdf: '' })) // PASS - Type error shown
// Test Intellisense - place cursor between the braces and try autocomplete
setState<Model>(customOperator({ })) // PASS
// Test Intellisense - place cursor between the braces as next prop after foo and try autocomplete
setState<Model>(customOperator({ foo: 1, })) // ## FAILED! ## NO INTELLISENSE
/* EXPLICIT TYPES */
// Normal usage
setState<Model>(customOperator<Model>({ foo: 1 })) // PASS
// Should fail unknown prop
setState<Model>(customOperator<Model>({ foo: 1, asdf: '' })) // PASS - Type error shown
// Should fail all props unknown
setState<Model>(customOperator<Model>({ asdf: '' })) // PASS - Type error shown
// Test Intellisense - place cursor between the braces and try autocomplete
setState<Model>(customOperator<Model>({})) // PASS
// Test Intellisense - place cursor between the braces as next prop after foo and try autocomplete
setState<Model>(customOperator<Model>({ foo: 1, })) // PASS
}
|
Beta Was this translation helpful? Give feedback.
-
Behold! One little trick, and everything works as expected. // Behold! The little `extends infer S ? S : never` trick allows Typescript to infer the intended shape of T from
// the usage of customOperator within a surrounding function.
// I forgot why this works, but NgRx uses this trick for their `on` function in their reducers.
function customOperator<T>(patchObject: PatchSpec<T extends infer S ? S : never>): StateOperator<T> {
const patchValue: Partial<T> = patchObject as any; // Implementation not included
return (state: T) => ({ ...state, ...patchValue });
} |
Beta Was this translation helpful? Give feedback.
Behold! One little trick, and everything works as expected.