Skip to content

Commit 10c93ed

Browse files
fix(signals): improve state type and add type tests (#4064)
Closes #4065
1 parent fd565ed commit 10c93ed

13 files changed

+1438
-56
lines changed

modules/signals/spec/types/helpers.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export const compilerOptions = () => ({
2+
moduleResolution: 'node',
3+
target: 'ES2022',
4+
baseUrl: '.',
5+
experimentalDecorators: true,
6+
strict: true,
7+
noImplicitAny: true,
8+
paths: {
9+
'@ngrx/signals': ['./modules/signals'],
10+
},
11+
});
Lines changed: 387 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,387 @@
1+
import { expecter } from 'ts-snippet';
2+
import { compilerOptions } from './helpers';
3+
4+
describe('signalState', () => {
5+
const expectSnippet = expecter(
6+
(code) => `
7+
import { patchState, signalState } from '@ngrx/signals';
8+
9+
const initialState = {
10+
user: {
11+
age: 30,
12+
details: {
13+
first: 'John',
14+
last: 'Smith',
15+
},
16+
address: ['Belgrade', 'Serbia'],
17+
},
18+
numbers: [1, 2, 3],
19+
ngrx: 'rocks',
20+
};
21+
22+
${code}
23+
`,
24+
compilerOptions()
25+
);
26+
27+
it('allows passing state as a generic argument', () => {
28+
const snippet = `
29+
type FooState = { foo: string; bar: number };
30+
const state = signalState<FooState>({ foo: 'bar', bar: 1 });
31+
`;
32+
33+
expectSnippet(snippet).toSucceed();
34+
35+
expectSnippet(snippet).toInfer('state', 'SignalState<FooState>');
36+
});
37+
38+
it('creates deep signals for nested state slices', () => {
39+
const snippet = `
40+
const state = signalState(initialState);
41+
42+
const user = state.user;
43+
const age = state.user.age;
44+
const details = state.user.details;
45+
const first = state.user.details.first;
46+
const last = state.user.details.last;
47+
const address = state.user.address;
48+
const numbers = state.numbers;
49+
const ngrx = state.ngrx;
50+
`;
51+
52+
expectSnippet(snippet).toSucceed();
53+
54+
expectSnippet(snippet).toInfer(
55+
'state',
56+
'SignalState<{ user: { age: number; details: { first: string; last: string; }; address: string[]; }; numbers: number[]; ngrx: string; }>'
57+
);
58+
59+
expectSnippet(snippet).toInfer(
60+
'user',
61+
'DeepSignal<{ age: number; details: { first: string; last: string; }; address: string[]; }>'
62+
);
63+
64+
expectSnippet(snippet).toInfer(
65+
'details',
66+
'DeepSignal<{ first: string; last: string; }>'
67+
);
68+
69+
expectSnippet(snippet).toInfer('first', 'Signal<string>');
70+
71+
expectSnippet(snippet).toInfer('last', 'Signal<string>');
72+
73+
expectSnippet(snippet).toInfer('address', 'Signal<string[]>');
74+
75+
expectSnippet(snippet).toInfer('numbers', 'Signal<number[]>');
76+
77+
expectSnippet(snippet).toInfer('ngrx', 'Signal<string>');
78+
});
79+
80+
it('does not create deep signals when state slice type is an interface', () => {
81+
const snippet = `
82+
interface User {
83+
firstName: string;
84+
lastName: string;
85+
}
86+
87+
type State = { user: User };
88+
89+
const state = signalState<State>({ user: { firstName: 'John', lastName: 'Smith' } });
90+
const user = state.user;
91+
`;
92+
93+
expectSnippet(snippet).toSucceed();
94+
95+
expectSnippet(snippet).toInfer('user', 'Signal<User>');
96+
});
97+
98+
it('does not create deep signals for optional state slices', () => {
99+
const snippet = `
100+
type State = {
101+
foo?: string;
102+
bar: { baz?: number };
103+
x?: { y: { z?: boolean } };
104+
};
105+
106+
const state = signalState<State>({ bar: {} });
107+
const foo = state.foo;
108+
const bar = state.bar;
109+
const baz = state.bar.baz;
110+
const x = state.x;
111+
`;
112+
113+
expectSnippet(snippet).toSucceed();
114+
115+
expectSnippet(snippet).toInfer('state', 'SignalState<State>');
116+
117+
expectSnippet(snippet).toInfer(
118+
'foo',
119+
'Signal<string | undefined> | undefined'
120+
);
121+
122+
expectSnippet(snippet).toInfer(
123+
'bar',
124+
'DeepSignal<{ baz?: number | undefined; }>'
125+
);
126+
127+
expectSnippet(snippet).toInfer(
128+
'baz',
129+
'Signal<number | undefined> | undefined'
130+
);
131+
132+
expectSnippet(snippet).toInfer(
133+
'x',
134+
'Signal<{ y: { z?: boolean | undefined; }; } | undefined> | undefined'
135+
);
136+
});
137+
138+
it('does not create deep signals for unknown records', () => {
139+
const snippet = `
140+
const state1 = signalState<{ [key: string]: number }>({});
141+
declare const state1Keys: keyof typeof state1;
142+
143+
const state2 = signalState<{ [key: number]: { foo: string } }>({
144+
1: { foo: 'bar' },
145+
});
146+
declare const state2Keys: keyof typeof state2;
147+
148+
const state3 = signalState<Record<string, { bar: number }>>({});
149+
declare const state3Keys: keyof typeof state3;
150+
151+
const state4 = signalState({
152+
foo: {} as Record<string, { bar: boolean } | number>,
153+
});
154+
const foo = state4.foo;
155+
156+
const state5 = signalState({
157+
bar: { baz: {} as Record<number, unknown> }
158+
});
159+
const bar = state5.bar;
160+
const baz = bar.baz;
161+
`;
162+
163+
expectSnippet(snippet).toSucceed();
164+
165+
expectSnippet(snippet).toInfer(
166+
'state1',
167+
'SignalState<{ [key: string]: number; }>'
168+
);
169+
170+
expectSnippet(snippet).toInfer(
171+
'state1Keys',
172+
'unique symbol | unique symbol'
173+
);
174+
175+
expectSnippet(snippet).toInfer(
176+
'state2',
177+
'SignalState<{ [key: number]: { foo: string; }; }>'
178+
);
179+
180+
expectSnippet(snippet).toInfer(
181+
'state2Keys',
182+
'unique symbol | unique symbol'
183+
);
184+
185+
expectSnippet(snippet).toInfer(
186+
'state3',
187+
'SignalState<Record<string, { bar: number; }>>'
188+
);
189+
190+
expectSnippet(snippet).toInfer(
191+
'state3Keys',
192+
'unique symbol | unique symbol'
193+
);
194+
195+
expectSnippet(snippet).toInfer(
196+
'state4',
197+
'SignalState<{ foo: Record<string, number | { bar: boolean; }>; }>'
198+
);
199+
200+
expectSnippet(snippet).toInfer(
201+
'foo',
202+
'Signal<Record<string, number | { bar: boolean; }>>'
203+
);
204+
205+
expectSnippet(snippet).toInfer(
206+
'state5',
207+
'SignalState<{ bar: { baz: Record<number, unknown>; }; }>'
208+
);
209+
210+
expectSnippet(snippet).toInfer(
211+
'bar',
212+
'DeepSignal<{ baz: Record<number, unknown>; }>'
213+
);
214+
215+
expectSnippet(snippet).toInfer('baz', 'Signal<Record<number, unknown>>');
216+
});
217+
218+
it('succeeds when state is an empty object', () => {
219+
const snippet = `const state = signalState({})`;
220+
221+
expectSnippet(snippet).toSucceed();
222+
223+
expectSnippet(snippet).toInfer('state', 'SignalState<{}>');
224+
});
225+
226+
it('succeeds when state slices are union types', () => {
227+
const snippet = `
228+
type State = {
229+
foo: { s: string } | number;
230+
bar: { baz: { n: number } | null };
231+
x: { y: { z: boolean | undefined } };
232+
};
233+
234+
const state = signalState<State>({
235+
foo: { s: 's' },
236+
bar: { baz: null },
237+
x: { y: { z: undefined } },
238+
});
239+
const foo = state.foo;
240+
const bar = state.bar;
241+
const baz = state.bar.baz;
242+
const x = state.x;
243+
const y = state.x.y;
244+
const z = state.x.y.z;
245+
`;
246+
247+
expectSnippet(snippet).toSucceed();
248+
249+
expectSnippet(snippet).toInfer('state', 'SignalState<State>');
250+
251+
expectSnippet(snippet).toInfer('foo', 'Signal<number | { s: string; }>');
252+
253+
expectSnippet(snippet).toInfer(
254+
'bar',
255+
'DeepSignal<{ baz: { n: number; } | null; }>'
256+
);
257+
258+
expectSnippet(snippet).toInfer('baz', 'Signal<{ n: number; } | null>');
259+
260+
expectSnippet(snippet).toInfer(
261+
'x',
262+
'DeepSignal<{ y: { z: boolean | undefined; }; }>'
263+
);
264+
265+
expectSnippet(snippet).toInfer(
266+
'y',
267+
'DeepSignal<{ z: boolean | undefined; }>'
268+
);
269+
270+
expectSnippet(snippet).toInfer('z', 'Signal<boolean | undefined>');
271+
});
272+
273+
it('fails when state contains Function properties', () => {
274+
expectSnippet(`const state = signalState({ name: '' })`).toFail(
275+
/@ngrx\/signals: signal state cannot contain `Function` property or method names/
276+
);
277+
278+
expectSnippet(
279+
`const state = signalState({ foo: { arguments: [] } })`
280+
).toFail(
281+
/@ngrx\/signals: signal state cannot contain `Function` property or method names/
282+
);
283+
284+
expectSnippet(`
285+
type State = { foo: { bar: { call?: boolean }; baz: number } };
286+
const state = signalState<State>({ foo: { bar: {}, baz: 1 } });
287+
`).toFail(
288+
/@ngrx\/signals: signal state cannot contain `Function` property or method names/
289+
);
290+
291+
expectSnippet(
292+
`const state = signalState({ foo: { apply: 'apply', bar: true } })`
293+
).toFail(
294+
/@ngrx\/signals: signal state cannot contain `Function` property or method names/
295+
);
296+
297+
expectSnippet(`
298+
type State = { bind?: { foo: string } };
299+
const state = signalState<State>({ bind: { foo: 'bar' } });
300+
`).toFail(
301+
/@ngrx\/signals: signal state cannot contain `Function` property or method names/
302+
);
303+
304+
expectSnippet(
305+
`const state = signalState({ foo: { bar: { prototype: [] }; baz: 1 } })`
306+
).toFail(
307+
/@ngrx\/signals: signal state cannot contain `Function` property or method names/
308+
);
309+
310+
expectSnippet(`const state = signalState({ foo: { length: 10 } })`).toFail(
311+
/@ngrx\/signals: signal state cannot contain `Function` property or method names/
312+
);
313+
314+
expectSnippet(`const state = signalState({ caller: '' })`).toFail(
315+
/@ngrx\/signals: signal state cannot contain `Function` property or method names/
316+
);
317+
});
318+
319+
it('fails when state is not an object', () => {
320+
expectSnippet(`const state = signalState(10);`).toFail();
321+
322+
expectSnippet(`const state = signalState('');`).toFail();
323+
324+
expectSnippet(`const state = signalState(null);`).toFail();
325+
326+
expectSnippet(`const state = signalState(true);`).toFail();
327+
328+
expectSnippet(`const state = signalState(['ng', 'rx']);`).toFail();
329+
});
330+
331+
it('fails when state type is defined as an interface', () => {
332+
expectSnippet(`
333+
interface User {
334+
firstName: string;
335+
lastName: string;
336+
}
337+
338+
const state = signalState<User>({ firstName: 'John', lastName: 'Smith' });
339+
`).toFail(
340+
/Type 'User' does not satisfy the constraint 'Record<string, unknown>'/
341+
);
342+
});
343+
344+
it('patches state via sequence of partial state objects and updater functions', () => {
345+
expectSnippet(`
346+
const state = signalState(initialState);
347+
348+
patchState(
349+
state,
350+
{ numbers: [10, 100, 1000] },
351+
(state) => ({ user: { ...state.user, age: state.user.age + 1 } }),
352+
{ ngrx: 'signals' }
353+
);
354+
`).toSucceed();
355+
});
356+
357+
it('fails when state is patched with a non-record', () => {
358+
expectSnippet(`
359+
const state = signalState(initialState);
360+
patchState(state, 10);
361+
`).toFail();
362+
363+
expectSnippet(`
364+
const state = signalState(initialState);
365+
patchState(state, undefined);
366+
`).toFail();
367+
368+
expectSnippet(`
369+
const state = signalState(initialState);
370+
patchState(state, [1, 2, 3]);
371+
`).toFail();
372+
});
373+
374+
it('fails when state is patched with a wrong record', () => {
375+
expectSnippet(`
376+
const state = signalState(initialState);
377+
patchState(state, { ngrx: 10 });
378+
`).toFail(/Type 'number' is not assignable to type 'string'/);
379+
});
380+
381+
it('fails when state is patched with a wrong updater function', () => {
382+
expectSnippet(`
383+
const state = signalState(initialState);
384+
patchState(state, (state) => ({ user: { ...state.user, age: '30' } }));
385+
`).toFail(/Type 'string' is not assignable to type 'number'/);
386+
});
387+
});

0 commit comments

Comments
 (0)