diff --git a/modules/signals/spec/types/helpers.ts b/modules/signals/spec/types/helpers.ts deleted file mode 100644 index 838b96145a..0000000000 --- a/modules/signals/spec/types/helpers.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const compilerOptions = () => ({ - module: 'preserve', - moduleResolution: 'bundler', - target: 'ES2022', - baseUrl: '.', - experimentalDecorators: true, - strict: true, - noImplicitAny: true, - paths: { - '@ngrx/signals': ['./modules/signals'], - }, -}); diff --git a/modules/signals/spec/types/patch-state.types.spec.ts b/modules/signals/spec/types/patch-state.types.spec.ts index 16c1afc810..51236dcb36 100644 --- a/modules/signals/spec/types/patch-state.types.spec.ts +++ b/modules/signals/spec/types/patch-state.types.spec.ts @@ -1,78 +1,55 @@ -import { expecter } from 'ts-snippet'; -import { compilerOptions } from './helpers'; +import { PartialStateUpdater, patchState, signalState } from '@ngrx/signals'; -describe('patchState', () => { - const expectSnippet = expecter( - (code) => ` - import { - PartialStateUpdater, - patchState, - signalState, - } from '@ngrx/signals'; - - const state = signalState({ count: 1, foo: 'bar' }); - - function increment(): PartialStateUpdater<{ count: number }> { - return ({ count }) => ({ count: count + 1 }); - } +function increment(): PartialStateUpdater<{ count: number }> { + return ({ count }) => ({ count: count + 1 }); +} - function addNumber(num: number): PartialStateUpdater<{ - numbers: number[]; - }> { - return ({ numbers }) => ({ numbers: [...numbers, num] }); - } - - ${code} - `, - compilerOptions() - ); +function addNumber(num: number): PartialStateUpdater<{ numbers: number[] }> { + return ({ numbers }) => ({ numbers: [...numbers, num] }); +} +describe('patchState', () => { it('infers the state type from WritableStateSource with updater', () => { - expectSnippet('patchState(state, increment())').toSucceed(); + const state = signalState({ count: 1, foo: 'bar' }); + patchState(state, increment()); }); it('infers the state type from WritableStateSource with object', () => { - expectSnippet("patchState(state, { foo: 'baz' })").toSucceed(); + const state = signalState({ count: 1, foo: 'bar' }); + patchState(state, { foo: 'baz' }); }); it('infers the state type from WritableStateSource with updater and object', () => { - expectSnippet("patchState(state, { foo: 'baz' }, increment())").toSucceed(); - expectSnippet("patchState(state, increment(), { foo: 'baz' })").toSucceed(); + const state = signalState({ count: 1, foo: 'bar' }); + patchState(state, { foo: 'baz' }, increment()); + patchState(state, increment(), { foo: 'baz' }); }); it('fails with wrong partial state object', () => { - expectSnippet('patchState(state, { x: 1 })').toFail( - /'x' does not exist in type 'Partial>/ - ); - expectSnippet("patchState(state, { foo: 'baz' }, { x: 1 })").toFail( - /'x' does not exist in type 'Partial>/ - ); - expectSnippet('patchState(state, { x: 1 }, { count: 0 })').toFail( - /'x' does not exist in type 'Partial>/ - ); - expectSnippet('patchState(state, increment(), { x: 1 })').toFail( - /'x' does not exist in type 'Partial>/ - ); - expectSnippet('patchState(state, { x: 1 }, increment())').toFail( - /'x' does not exist in type 'Partial>/ - ); + const state = signalState({ count: 1, foo: 'bar' }); + // @ts-expect-error - 'x' does not exist in type 'Partial> | PartialStateUpdater>' + patchState(state, { x: 1 }); + // @ts-expect-error - 'x' does not exist in type 'Partial> | PartialStateUpdater>' + patchState(state, { foo: 'baz' }, { x: 1 }); + // @ts-expect-error - 'x' does not exist in type 'Partial> | PartialStateUpdater>' + patchState(state, { x: 1 }, { count: 0 }); + // @ts-expect-error - 'x' does not exist in type 'Partial> | PartialStateUpdater>' + patchState(state, increment(), { x: 1 }); + // @ts-expect-error - 'x' does not exist in type 'Partial> | PartialStateUpdater>' + patchState(state, { x: 1 }, increment()); }); it('fails with wrong partial state updater', () => { - expectSnippet('patchState(state, addNumber(10))').toFail( - /Property 'numbers' is missing in type '{ count: number; foo: string; }'/ - ); - expectSnippet('patchState(state, { count: 10 }, addNumber(10))').toFail( - /Property 'numbers' is missing in type '{ count: number; foo: string; }'/ - ); - expectSnippet('patchState(state, addNumber(10), { count: 10 })').toFail( - /Property 'numbers' is missing in type '{ count: number; foo: string; }'/ - ); - expectSnippet('patchState(state, increment(), addNumber(10))').toFail( - /Property 'numbers' is missing in type '{ count: number; foo: string; }'/ - ); - expectSnippet('patchState(state, addNumber(10), increment())').toFail( - /Property 'numbers' is missing in type '{ count: number; foo: string; }'/ - ); + const state = signalState({ count: 1, foo: 'bar' }); + // @ts-expect-error - PartialStateUpdater<{ numbers: number[]; }> is not assignable to PartialStateUpdater> + const test = () => patchState(state, addNumber(10)); + // @ts-expect-error - PartialStateUpdater<{ numbers: number[]; }> is not assignable to PartialStateUpdater> + const test2 = () => patchState(state, { count: 10 }, addNumber(10)); + // @ts-expect-error - PartialStateUpdater<{ numbers: number[]; }> is not assignable to PartialStateUpdater> + const test3 = () => patchState(state, addNumber(10), { count: 10 }); + // @ts-expect-error - PartialStateUpdater<{ numbers: number[]; }> is not assignable to PartialStateUpdater> + const test4 = () => patchState(state, increment(), addNumber(10)); + // @ts-expect-error - PartialStateUpdater<{ numbers: number[]; }> is not assignable to PartialStateUpdater> + const test5 = () => patchState(state, addNumber(10), increment()); }); -}, 8_000); +}); diff --git a/modules/signals/spec/types/signal-state.types.spec.ts b/modules/signals/spec/types/signal-state.types.spec.ts index 803379df6e..60c4207e2f 100644 --- a/modules/signals/spec/types/signal-state.types.spec.ts +++ b/modules/signals/spec/types/signal-state.types.spec.ts @@ -1,373 +1,355 @@ -import { expecter } from 'ts-snippet'; -import { compilerOptions } from './helpers'; +import { expectTypeOf } from 'vitest'; +import { Signal } from '@angular/core'; +import { + DeepSignal, + patchState, + signalState, + SignalState, +} from '@ngrx/signals'; + +const initialState = { + user: { + age: 30, + details: { + first: 'John', + last: 'Smith', + }, + address: ['Belgrade', 'Serbia'], + }, + numbers: [1, 2, 3], + ngrx: 'rocks', +}; describe('signalState', () => { - const expectSnippet = expecter( - (code) => ` - import { patchState, signalState } from '@ngrx/signals'; - - const initialState = { - user: { - age: 30, - details: { - first: 'John', - last: 'Smith', - }, - address: ['Belgrade', 'Serbia'], - }, - numbers: [1, 2, 3], - ngrx: 'rocks', - }; - - ${code} - `, - compilerOptions() - ); - it('allows passing state as a generic argument', () => { - const snippet = ` - type FooState = { foo: string; bar: number }; - const state = signalState({ foo: 'bar', bar: 1 }); - `; + type FooState = { foo: string; bar: number }; + const state = signalState({ foo: 'bar', bar: 1 }); - const result = expectSnippet(snippet); - - result.toInfer('state', 'SignalState'); + expectTypeOf(state).toEqualTypeOf>(); }); it('creates deep signals for nested state slices', () => { - const snippet = ` - const state = signalState(initialState); - - const user = state.user; - const age = state.user.age; - const details = state.user.details; - const first = state.user.details.first; - const last = state.user.details.last; - const address = state.user.address; - const numbers = state.numbers; - const ngrx = state.ngrx; - `; - - const result = expectSnippet(snippet); - result.toInfer( - 'state', - 'SignalState<{ user: { age: number; details: { first: string; last: string; }; address: string[]; }; numbers: number[]; ngrx: string; }>' - ); - result.toInfer( - 'user', - 'DeepSignal<{ age: number; details: { first: string; last: string; }; address: string[]; }>' - ); - result.toInfer('details', 'DeepSignal<{ first: string; last: string; }>'); - result.toInfer('first', 'Signal'); - result.toInfer('last', 'Signal'); - result.toInfer('address', 'Signal'); - result.toInfer('numbers', 'Signal'); - result.toInfer('ngrx', 'Signal'); + const state = signalState(initialState); + + const user = state.user; + const age = state.user.age; + const details = state.user.details; + const first = state.user.details.first; + const last = state.user.details.last; + const address = state.user.address; + const numbers = state.numbers; + const ngrx = state.ngrx; + + expectTypeOf(state).toEqualTypeOf< + SignalState<{ + user: { + age: number; + details: { first: string; last: string }; + address: string[]; + }; + numbers: number[]; + ngrx: string; + }> + >(); + expectTypeOf(user).toEqualTypeOf< + DeepSignal<{ + age: number; + details: { first: string; last: string }; + address: string[]; + }> + >(); + expectTypeOf(details).toEqualTypeOf< + DeepSignal<{ first: string; last: string }> + >(); + expectTypeOf(age).toEqualTypeOf>(); + expectTypeOf(first).toEqualTypeOf>(); + expectTypeOf(last).toEqualTypeOf>(); + expectTypeOf(address).toEqualTypeOf>(); + expectTypeOf(numbers).toEqualTypeOf>(); + expectTypeOf(ngrx).toEqualTypeOf>(); }); it('creates deep signals when state type is an interface', () => { - const snippet = ` - interface User { - firstName: string; - lastName: string; - } - - interface State { - user: User; - bool: boolean; - map: Map; - set: Set<{ foo: number }>; - }; - - const state = signalState({ - user: { firstName: 'John', lastName: 'Smith' }, - bool: true, - map: new Map(), - set: new Set<{ foo: number }>(), - }); - - const user = state.user; - const lastName = state.user.lastName; - const bool = state.bool; - const map = state.map; - const set = state.set; - `; - - const result = expectSnippet(snippet); - result.toInfer('user', 'DeepSignal'); - result.toInfer('lastName', 'Signal'); - result.toInfer('bool', 'Signal'); - result.toInfer('map', 'Signal>'); - result.toInfer('set', 'Signal>'); + interface User { + firstName: string; + lastName: string; + } + + interface State { + user: User; + bool: boolean; + map: Map; + set: Set<{ foo: number }>; + } + + const state = signalState({ + user: { firstName: 'John', lastName: 'Smith' }, + bool: true, + map: new Map(), + set: new Set<{ foo: number }>(), + }); + + const user = state.user; + const lastName = state.user.lastName; + const bool = state.bool; + const map = state.map; + const set = state.set; + + expectTypeOf(user).toEqualTypeOf>(); + expectTypeOf(lastName).toEqualTypeOf>(); + expectTypeOf(bool).toEqualTypeOf>(); + expectTypeOf(map).toEqualTypeOf>>(); + expectTypeOf(set).toEqualTypeOf>>(); }); it('does not create deep signals for iterables', () => { - const snippet = ` - const arrayState = signalState([]); - const arrayStateValue = arrayState(); - declare const arrayStateKeys: keyof typeof arrayState; - - const setState = signalState(new Set()); - const setStateValue = setState(); - declare const setStateKeys: keyof typeof setState; - - const mapState = signalState(new Map()); - const mapStateValue = mapState(); - declare const mapStateKeys: keyof typeof mapState; - - const uintArrayState = signalState(new Uint8ClampedArray()); - const uintArrayStateValue = uintArrayState(); - declare const uintArrayStateKeys: keyof typeof uintArrayState; - `; - - const result = expectSnippet(snippet); - result.toInfer('arrayStateValue', 'string[]'); - result.toInfer('arrayStateKeys', 'unique symbol | unique symbol'); - result.toInfer('setStateValue', 'Set'); - result.toInfer('setStateKeys', 'unique symbol | unique symbol'); - result.toInfer('mapStateValue', 'Map'); - result.toInfer('mapStateKeys', 'unique symbol | unique symbol'); - result.toInfer('uintArrayStateValue', 'Uint8ClampedArray'); - result.toInfer('uintArrayStateKeys', 'unique symbol | unique symbol'); + const arrayState = signalState([]); + const arrayStateValue = arrayState(); + + const setState = signalState(new Set()); + const setStateValue = setState(); + + const mapState = signalState(new Map()); + const mapStateValue = mapState(); + + const uintArrayState = signalState(new Uint8ClampedArray()); + const uintArrayStateValue = uintArrayState(); + + expectTypeOf(arrayStateValue).toEqualTypeOf(); + expectTypeOf().toBeNever(); + + expectTypeOf(setStateValue).toEqualTypeOf>(); + expectTypeOf().toBeNever(); + + expectTypeOf(mapStateValue).toEqualTypeOf>(); + expectTypeOf().toBeNever(); + + expectTypeOf(uintArrayStateValue).toEqualTypeOf(); + expectTypeOf().toBeNever(); }); it('does not create deep signals for built-in object types', () => { - const snippet = ` - const weakSetState = signalState(new WeakSet<{ foo: string }>()); - const weakSetStateValue = weakSetState(); - declare const weakSetStateKeys: keyof typeof weakSetState; - - const dateState = signalState(new Date()); - const dateStateValue = dateState(); - declare const dateStateKeys: keyof typeof dateState; - - const errorState = signalState(new Error()); - const errorStateValue = errorState(); - declare const errorStateKeys: keyof typeof errorState; - - const regExpState = signalState(new RegExp('')); - const regExpStateValue = regExpState(); - declare const regExpStateKeys: keyof typeof regExpState; - `; - - const result = expectSnippet(snippet); - result.toInfer('weakSetStateValue', 'WeakSet<{ foo: string; }>'); - result.toInfer('weakSetStateKeys', 'unique symbol | unique symbol'); - result.toInfer('dateStateValue', 'Date'); - result.toInfer('dateStateKeys', 'unique symbol | unique symbol'); - result.toInfer('errorStateValue', 'Error'); - result.toInfer('errorStateKeys', 'unique symbol | unique symbol'); - result.toInfer('regExpStateValue', 'RegExp'); - result.toInfer('regExpStateKeys', 'unique symbol | unique symbol'); + const weakSetState = signalState(new WeakSet<{ foo: string }>()); + const weakSetStateValue = weakSetState(); + + const dateState = signalState(new Date()); + const dateStateValue = dateState(); + + const errorState = signalState(new Error()); + const errorStateValue = errorState(); + + const regExpState = signalState(new RegExp('')); + const regExpStateValue = regExpState(); + + expectTypeOf(weakSetStateValue).toEqualTypeOf>(); + expectTypeOf().toBeNever(); + + expectTypeOf(dateStateValue).toEqualTypeOf(); + expectTypeOf().toBeNever(); + + expectTypeOf(errorStateValue).toEqualTypeOf(); + expectTypeOf().toBeNever(); + + expectTypeOf(regExpStateValue).toEqualTypeOf(); + expectTypeOf().toBeNever(); }); it('does not create deep signals for functions', () => { - const snippet = ` - const state = signalState(() => {}); - const stateValue = state(); - declare const stateKeys: keyof typeof state; - `; - - const result = expectSnippet(snippet); - result.toInfer('stateValue', '() => void'); - result.toInfer('stateKeys', 'unique symbol | unique symbol'); + const state = signalState(() => {}); + const stateValue = state(); + + expectTypeOf(stateValue).toEqualTypeOf<() => void>(); + expectTypeOf().toBeNever(); }); it('does not create deep signals for optional state slices', () => { - const snippet = ` - type State = { - foo?: string; - bar: { baz?: number }; - x?: { y: { z?: boolean } }; - }; - - const state = signalState({ bar: {} }); - const foo = state.foo; - const bar = state.bar; - const baz = state.bar.baz; - const x = state.x; - `; - - const result = expectSnippet(snippet); - result.toInfer('state', 'SignalState'); - result.toInfer('foo', 'Signal | undefined'); - result.toInfer('bar', 'DeepSignal<{ baz?: number | undefined; }>'); - result.toInfer('baz', 'Signal | undefined'); - result.toInfer( - 'x', - 'Signal<{ y: { z?: boolean | undefined; }; } | undefined> | undefined' - ); + type State = { + foo?: string; + bar: { baz?: number }; + x?: { y: { z?: boolean } }; + }; + + const state = signalState({ bar: {} }); + const foo = state.foo; + const bar = state.bar; + const baz = state.bar.baz; + const x = state.x; + + expectTypeOf(state).toEqualTypeOf>(); + expectTypeOf(foo).toEqualTypeOf | undefined>(); + expectTypeOf(bar).toEqualTypeOf>(); + expectTypeOf(baz).toEqualTypeOf | undefined>(); + expectTypeOf(x).toEqualTypeOf< + Signal<{ y: { z?: boolean | undefined } } | undefined> | undefined + >(); }); it('does not create deep signals for unknown records', () => { - const snippet = ` - const state1 = signalState<{ [key: string]: number }>({}); - declare const state1Keys: keyof typeof state1; - - const state2 = signalState<{ [key: number]: { foo: string } }>({ - 1: { foo: 'bar' }, - }); - declare const state2Keys: keyof typeof state2; - - const state3 = signalState>({}); - declare const state3Keys: keyof typeof state3; - - const state4 = signalState({ - foo: {} as Record, - }); - const foo = state4.foo; - - const state5 = signalState({ - bar: { baz: {} as Record } - }); - const bar = state5.bar; - const baz = bar.baz; - - const state6 = signalState({ - x: {} as Record - }); - const x = state6.x; - - const state7 = signalState({ y: {} }); - const y = state7.y; - `; - - const result = expectSnippet(snippet); - result.toInfer('state1', 'SignalState<{ [key: string]: number; }>'); - result.toInfer('state1Keys', 'unique symbol | unique symbol'); - result.toInfer( - 'state2', - 'SignalState<{ [key: number]: { foo: string; }; }>' - ); - result.toInfer('state2Keys', 'unique symbol | unique symbol'); - result.toInfer('state3', 'SignalState>'); - result.toInfer('state3Keys', 'unique symbol | unique symbol'); - result.toInfer( - 'state4', - 'SignalState<{ foo: Record; }>' - ); - result.toInfer('foo', 'Signal>'); - result.toInfer( - 'state5', - 'SignalState<{ bar: { baz: Record; }; }>' - ); - result.toInfer('bar', 'DeepSignal<{ baz: Record; }>'); - result.toInfer('baz', 'Signal>'); - result.toInfer('state6', 'SignalState<{ x: Record; }>'); - result.toInfer('x', 'Signal>'); - result.toInfer('state7', 'SignalState<{ y: {}; }>'); - result.toInfer('y', 'Signal<{}>'); + const state1 = signalState<{ [key: string]: number }>({}); + const state2 = signalState<{ [key: number]: { foo: string } }>({ + 1: { foo: 'bar' }, + }); + const state3 = signalState>({}); + const state4 = signalState({ + foo: {} as Record, + }); + const foo = state4.foo; + const state5 = signalState({ + bar: { baz: {} as Record }, + }); + const bar = state5.bar; + const baz = bar.baz; + const state6 = signalState({ + x: {} as Record, + }); + const x = state6.x; + const state7 = signalState({ y: {} }); + const y = state7.y; + + expectTypeOf(state1).toEqualTypeOf< + SignalState<{ [key: string]: number }> + >(); + expectTypeOf().toBeNever(); + + expectTypeOf(state2).toEqualTypeOf< + SignalState<{ [key: number]: { foo: string } }> + >(); + expectTypeOf().toBeNever(); + + expectTypeOf(state3).toEqualTypeOf< + SignalState> + >(); + expectTypeOf().toBeNever(); + + expectTypeOf(state4).toEqualTypeOf< + SignalState<{ foo: Record }> + >(); + expectTypeOf(foo).toEqualTypeOf< + Signal> + >(); + + expectTypeOf(state5).toEqualTypeOf< + SignalState<{ bar: { baz: Record } }> + >(); + expectTypeOf(bar).toEqualTypeOf< + DeepSignal<{ baz: Record }> + >(); + expectTypeOf(baz).toEqualTypeOf>>(); + + expectTypeOf(state6).toEqualTypeOf< + SignalState<{ x: Record }> + >(); + expectTypeOf(x).toEqualTypeOf>>(); + + expectTypeOf(state7).toEqualTypeOf>(); + expectTypeOf(y).toEqualTypeOf>(); }); it('succeeds when state is an empty object', () => { - const snippet = `const state = signalState({})`; - - const result = expectSnippet(snippet); - result.toInfer('state', 'SignalState<{}>'); + const state = signalState({}); + expectTypeOf(state).toEqualTypeOf>(); }); it('succeeds when state slices are union types', () => { - const snippet = ` - type State = { - foo: { s: string } | number; - bar: { baz: { n: number } | null }; - x: { y: { z: boolean | undefined } }; - }; - - const state = signalState({ - foo: { s: 's' }, - bar: { baz: null }, - x: { y: { z: undefined } }, - }); - const foo = state.foo; - const bar = state.bar; - const baz = state.bar.baz; - const x = state.x; - const y = state.x.y; - const z = state.x.y.z; - `; - - const result = expectSnippet(snippet); - result.toInfer('state', 'SignalState'); - result.toInfer('foo', 'Signal'); - result.toInfer('bar', 'DeepSignal<{ baz: { n: number; } | null; }>'); - result.toInfer('baz', 'Signal<{ n: number; } | null>'); - result.toInfer('x', 'DeepSignal<{ y: { z: boolean | undefined; }; }>'); - result.toInfer('y', 'DeepSignal<{ z: boolean | undefined; }>'); - result.toInfer('z', 'Signal'); + type State = { + foo: { s: string } | number; + bar: { baz: { n: number } | null }; + x: { y: { z: boolean | undefined } }; + }; + + const state = signalState({ + foo: { s: 's' }, + bar: { baz: null }, + x: { y: { z: undefined } }, + }); + const foo = state.foo; + const bar = state.bar; + const baz = state.bar.baz; + const x = state.x; + const y = state.x.y; + const z = state.x.y.z; + + expectTypeOf(state).toEqualTypeOf>(); + expectTypeOf(foo).toEqualTypeOf>(); + expectTypeOf(bar).toEqualTypeOf< + DeepSignal<{ baz: { n: number } | null }> + >(); + expectTypeOf(baz).toEqualTypeOf>(); + expectTypeOf(x).toEqualTypeOf< + DeepSignal<{ y: { z: boolean | undefined } }> + >(); + expectTypeOf(y).toEqualTypeOf>(); + expectTypeOf(z).toEqualTypeOf>(); }); it('succeeds when state contains Function properties', () => { - const snippet = ` - const state1 = signalState({ name: 0 }); - const state2 = signalState({ foo: { length: [] as boolean[] } }); - const state3 = signalState({ name: { length: '' } }); - - const name = state1.name; - const length1 = state2.foo.length; - const name2 = state3.name; - const length2 = state3.name.length; - `; - - const result = expectSnippet(snippet); - result.toInfer('name', 'Signal'); - result.toInfer('length1', 'Signal'); - result.toInfer('name2', 'DeepSignal<{ length: string; }>'); - result.toInfer('length2', 'Signal'); + const state1 = signalState({ name: 0 }); + const state2 = signalState({ foo: { length: [] as boolean[] } }); + const state3 = signalState({ name: { length: '' } }); + + const name = state1.name; + const length1 = state2.foo.length; + const name2 = state3.name; + const length2 = state3.name.length; + + expectTypeOf(name).toEqualTypeOf>(); + expectTypeOf(length1).toEqualTypeOf>(); + expectTypeOf(name2).toEqualTypeOf>(); + expectTypeOf(length2).toEqualTypeOf>(); }); it('fails when state is not an object', () => { - expectSnippet(`const state = signalState(10);`).toFail(); - - expectSnippet(`const state = signalState('');`).toFail(); - - expectSnippet(`const state = signalState(null);`).toFail(); - - expectSnippet(`const state = signalState(true);`).toFail(); + void (() => { + // @ts-expect-error - Type 'number' is not assignable to type 'object' + signalState(10); + // @ts-expect-error - Type 'string' is not assignable to type 'object' + signalState(''); + // @ts-expect-error - Type 'null' is not assignable to type 'object' + signalState(null); + // @ts-expect-error - Type 'boolean' is not assignable to type 'object' + signalState(true); + }); }); it('patches state via sequence of partial state objects and updater functions', () => { - expectSnippet(` - const state = signalState(initialState); - - patchState( - state, - { numbers: [10, 100, 1000] }, - (state) => ({ user: { ...state.user, age: state.user.age + 1 } }), - { ngrx: 'signals' } - ); - `).toSucceed(); + const state = signalState(initialState); + + patchState( + state, + { numbers: [10, 100, 1000] }, + (s) => ({ user: { ...s.user, age: s.user.age + 1 } }), + { ngrx: 'signals' } + ); }); it('fails when state is patched with a non-record', () => { - expectSnippet(` - const state = signalState(initialState); - patchState(state, 10); - `).toFail(); - - expectSnippet(` - const state = signalState(initialState); - patchState(state, undefined); - `).toFail(); - - expectSnippet(` - const state = signalState(initialState); - patchState(state, [1, 2, 3]); - `).toFail(); + const state = signalState(initialState); + // @ts-expect-error - Type 'number' is not assignable to type 'Partial>' + patchState(state, 10); + + const state2 = signalState(initialState); + // @ts-expect-error - Type 'undefined' is not assignable to type 'Partial>' + patchState(state2, undefined); + + const state3 = signalState(initialState); + // @ts-expect-error - Type 'number[]' is not assignable to type 'Partial>' + patchState(state3, [1, 2, 3]); }); it('fails when state is patched with a wrong record', () => { - expectSnippet(` - const state = signalState(initialState); - patchState(state, { ngrx: 10 }); - `).toFail(/Type 'number' is not assignable to type 'string'/); + const state = signalState(initialState); + // @ts-expect-error - Type 'number' is not assignable to type 'string' + patchState(state, { ngrx: 10 }); }); it('fails when state is patched with a wrong updater function', () => { - expectSnippet(` - const state = signalState(initialState); - patchState(state, (state) => ({ user: { ...state.user, age: '30' } })); - `).toFail(/Type 'string' is not assignable to type 'number'/); + const state = signalState(initialState); + // @ts-expect-error - Type 'string' is not assignable to type 'number' + patchState(state, (s) => ({ + user: { + ...s.user, + age: '30', + }, + })); }); -}, 8_000); +}); diff --git a/modules/signals/spec/types/signal-store.types.spec.ts b/modules/signals/spec/types/signal-store.types.spec.ts index d536832d9b..2cec7ab8cd 100644 --- a/modules/signals/spec/types/signal-store.types.spec.ts +++ b/modules/signals/spec/types/signal-store.types.spec.ts @@ -1,823 +1,693 @@ -import { expecter } from 'ts-snippet'; -import { compilerOptions } from './helpers'; +import { expectTypeOf } from 'vitest'; +import { computed, Signal, Type } from '@angular/core'; +import { + DeepSignal, + patchState, + signalStore, + signalStoreFeature, + StateSource, + type, + withComputed, + withHooks, + withMethods, + withState, + WritableStateSource, +} from '@ngrx/signals'; describe('signalStore', () => { - const expectSnippet = expecter( - (code) => ` - import { computed, inject, Signal } from '@angular/core'; - import { - getState, - patchState, - signalStore, - signalStoreFeature, - type, - withComputed, - withHooks, - withMethods, - withState, - } from '@ngrx/signals'; - - ${code} - `, - compilerOptions() - ); - it('allows passing state as a generic argument', () => { - const snippet = ` - type State = { foo: string; bar: number[] }; - const Store = signalStore( - withState({ foo: 'bar', bar: [1, 2] }) - ); - `; + type State = { foo: string; bar: number[] }; + const Store = signalStore(withState({ foo: 'bar', bar: [1, 2] })); - const result = expectSnippet(snippet); - result.toInfer( - 'Store', - 'Type<{ foo: Signal; bar: Signal; } & StateSource<{ foo: string; bar: number[]; }>>' - ); + expectTypeOf(Store).toEqualTypeOf< + Type<{ foo: Signal; bar: Signal } & StateSource> + >(); }); it('creates deep signals for nested state slices', () => { - const snippet = ` - const Store = signalStore( - withState({ - user: { - age: 10, - details: { - first: 'John', - flags: [true, false], - }, + const Store = signalStore( + withState({ + user: { + age: 10, + details: { + first: 'John', + flags: [true, false], }, - }) - ); - - const store = new Store(); - const user = store.user; - const age = store.user.age; - const details = store.user.details; - const first = store.user.details.first; - const flags = store.user.details.flags; - `; - - const result = expectSnippet(snippet); - result.toInfer( - 'store', - '{ user: DeepSignal<{ age: number; details: { first: string; flags: boolean[]; }; }>; } & StateSource<{ user: { age: number; details: { first: string; flags: boolean[]; }; }; }>' + }, + }) ); - result.toInfer( - 'user', - 'DeepSignal<{ age: number; details: { first: string; flags: boolean[]; }; }>' - ); - result.toInfer( - 'details', - 'DeepSignal<{ first: string; flags: boolean[]; }>' - ); - result.toInfer('first', 'Signal'); - result.toInfer('flags', 'Signal'); + + type S = InstanceType; + expectTypeOf().toEqualTypeOf< + { + user: DeepSignal<{ + age: number; + details: { first: string; flags: boolean[] }; + }>; + } & StateSource<{ + user: { age: number; details: { first: string; flags: boolean[] } }; + }> + >(); + expectTypeOf().toEqualTypeOf< + DeepSignal<{ age: number; details: { first: string; flags: boolean[] } }> + >(); + expectTypeOf().toEqualTypeOf< + DeepSignal<{ first: string; flags: boolean[] }> + >(); + expectTypeOf().toEqualTypeOf< + Signal + >(); + expectTypeOf().toEqualTypeOf< + Signal + >(); }); it('does not create deep signals when state slices are unknown records', () => { - const snippet = ` - type State = { - foo: { [key: string]: string }; - bar: { baz: Record }; - x: { y: { z: Record } }; - } + type State = { + foo: { [key: string]: string }; + bar: { baz: Record }; + x: { y: { z: Record } }; + }; + + const Store = signalStore( + withState({ foo: {}, bar: { baz: {} }, x: { y: { z: {} } } }) + ); - const Store = signalStore( - withState({ - foo: {}, - bar: { baz: {} }, - x: { y: { z: {} } }, - }) - ); + type S = InstanceType; + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf< + Signal> + >(); + expectTypeOf().toEqualTypeOf< + Signal> + >(); + }); - const store = new Store(); - const foo = store.foo; - const baz = store.bar.baz; - const z = store.x.y.z; - `; + it('creates deep signals when state type is an interface', () => { + interface User { + firstName: string; + lastName: string; + } + + interface State { + user: User; + num: number; + map: Map; + set: Set; + } + + const Store = signalStore( + withState({ + user: { firstName: 'John', lastName: 'Smith' }, + num: 10, + map: new Map(), + set: new Set(), + }) + ); - const result = expectSnippet(snippet); - result.toInfer('foo', 'Signal<{ [key: string]: string; }>'); - result.toInfer('baz', 'Signal>'); - result.toInfer('z', 'Signal>'); + type S = InstanceType; + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf< + Signal> + >(); + expectTypeOf().toEqualTypeOf>>(); }); - it('creates deep signals when state type is an interface', () => { - const snippet = ` - interface User { - firstName: string; - lastName: string; - } + it('does not create deep signals when state type is an iterable', () => { + const ArrayStore = signalStore(withState([])); + const arrayStore = null! as InstanceType; - interface State { - user: User; - num: number; - map: Map; - set: Set; - } + const SetStore = signalStore(withState(new Set<{ foo: string }>())); + const setStore = null! as InstanceType; - const Store = signalStore( - withState({ - user: { firstName: 'John', lastName: 'Smith' }, - num: 10, - map: new Map(), - set: new Set(), - }) - ); + const MapStore = signalStore(withState(new Map())); + const mapStore = null! as InstanceType; - const store = new Store(); - const user = store.user; - const firstName = store.user.firstName; - const num = store.num; - const map = store.map; - const set = store.set; - `; - - const result = expectSnippet(snippet); - result.toInfer('user', 'DeepSignal'); - result.toInfer('firstName', 'Signal'); - result.toInfer('num', 'Signal'); - result.toInfer('map', 'Signal>'); - result.toInfer('set', 'Signal>'); - }); + const FloatArrayStore = signalStore(withState(new Float32Array())); + const floatArrayStore = null! as InstanceType; - it('does not create deep signals when state type is an iterable', () => { - const snippet = ` - const ArrayStore = signalStore(withState([])); - const arrayStore = new ArrayStore(); - declare const arrayStoreKeys: keyof typeof arrayStore; - - const SetStore = signalStore(withState(new Set<{ foo: string }>())); - const setStore = new SetStore(); - declare const setStoreKeys: keyof typeof setStore; - - const MapStore = signalStore(withState(new Map())); - const mapStore = new MapStore(); - declare const mapStoreKeys: keyof typeof mapStore; - - const FloatArrayStore = signalStore(withState(new Float32Array())); - const floatArrayStore = new FloatArrayStore(); - declare const floatArrayStoreKeys: keyof typeof floatArrayStore; - `; - - const result = expectSnippet(snippet); - result.toInfer('arrayStoreKeys', 'unique symbol'); - result.toInfer('setStoreKeys', 'unique symbol'); - result.toInfer('mapStoreKeys', 'unique symbol'); - result.toInfer('floatArrayStoreKeys', 'unique symbol'); + expectTypeOf().toBeNever(); + expectTypeOf().toBeNever(); + expectTypeOf().toBeNever(); + expectTypeOf().toBeNever(); }); it('does not create deep signals when state type is a built-in object type', () => { - const snippet = ` - const WeakMapStore = signalStore(withState(new WeakMap<{ foo: string }, { bar: number }>())); - const weakMapStore = new WeakMapStore(); - declare const weakMapStoreKeys: keyof typeof weakMapStore; - - const DateStore = signalStore(withState(new Date())); - const dateStore = new DateStore(); - declare const dateStoreKeys: keyof typeof dateStore; - - const ErrorStore = signalStore(withState(new Error())); - const errorStore = new ErrorStore(); - declare const errorStoreKeys: keyof typeof errorStore; - - const RegExpStore = signalStore(withState(new RegExp(''))); - const regExpStore = new RegExpStore(); - declare const regExpStoreKeys: keyof typeof regExpStore; - `; - - const result = expectSnippet(snippet); - result.toInfer('weakMapStoreKeys', 'unique symbol'); - result.toInfer('dateStoreKeys', 'unique symbol'); - result.toInfer('errorStoreKeys', 'unique symbol'); - result.toInfer('regExpStoreKeys', 'unique symbol'); + const WeakMapStore = signalStore( + withState(new WeakMap<{ foo: string }, { bar: number }>()) + ); + const weakMapStore = null! as InstanceType; + + const DateStore = signalStore(withState(new Date())); + const dateStore = null! as InstanceType; + + const ErrorStore = signalStore(withState(new Error())); + const errorStore = null! as InstanceType; + + const RegExpStore = signalStore(withState(new RegExp(''))); + const regExpStore = null! as InstanceType; + + expectTypeOf().toBeNever(); + expectTypeOf().toBeNever(); + expectTypeOf().toBeNever(); + expectTypeOf().toBeNever(); }); it('does not create deep signals when state type is a function', () => { - const snippet = ` - const Store = signalStore(withState(() => () => {})); - const store = new Store(); - declare const storeKeys: keyof typeof store; - `; - - const result = expectSnippet(snippet); - result.toInfer('storeKeys', 'unique symbol'); + const Store = signalStore(withState(() => () => {})); + const store = null! as InstanceType; + + expectTypeOf().toBeNever(); }); it('succeeds when state is an empty object', () => { - const snippet = `const Store = signalStore(withState({}))`; + const Store = signalStore(withState({})); - const result = expectSnippet(snippet); - result.toInfer('Store', 'Type<{} & StateSource<{}>>'); + expectTypeOf(Store).toEqualTypeOf>>(); }); it('succeeds when state slices are union types', () => { - const snippet = ` - type State = { - foo: { s: string } | number; - bar: { baz: { b: boolean } | null }; - x: { y: { z: number | undefined } }; - }; - - const Store = signalStore( - withState({ - foo: { s: 's' }, - bar: { baz: null }, - x: { y: { z: undefined } }, - }) - ); - const store = inject(Store); - const foo = store.foo; - const bar = store.bar; - const baz = store.bar.baz; - const x = store.x; - const y = store.x.y; - const z = store.x.y.z; - `; - - const result = expectSnippet(snippet); - result.toInfer( - 'store', - '{ foo: Signal; bar: DeepSignal<{ baz: { b: boolean; } | null; }>; x: DeepSignal<{ y: { z: number | undefined; }; }>; } & StateSource<{ foo: number | { ...; }; bar: { ...; }; x: { ...; }; }>' + type State = { + foo: { s: string } | number; + bar: { baz: { b: boolean } | null }; + x: { y: { z: number | undefined } }; + }; + + const Store = signalStore( + withState({ + foo: { s: 's' }, + bar: { baz: null }, + x: { y: { z: undefined } }, + }) ); - result.toInfer('foo', 'Signal'); - result.toInfer('bar', 'DeepSignal<{ baz: { b: boolean; } | null; }>'); - result.toInfer('baz', 'Signal<{ b: boolean; } | null>'); - result.toInfer('x', 'DeepSignal<{ y: { z: number | undefined; }; }>'); - result.toInfer('y', 'DeepSignal<{ z: number | undefined; }>'); - result.toInfer('z', 'Signal'); + type S = InstanceType; + expectTypeOf().toEqualTypeOf< + { + foo: Signal; + bar: DeepSignal<{ baz: { b: boolean } | null }>; + x: DeepSignal<{ y: { z: number | undefined } }>; + } & StateSource + >(); + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf< + DeepSignal<{ baz: { b: boolean } | null }> + >(); + expectTypeOf().toEqualTypeOf< + Signal<{ b: boolean } | null> + >(); + expectTypeOf().toEqualTypeOf< + DeepSignal<{ y: { z: number | undefined } }> + >(); + expectTypeOf().toEqualTypeOf< + DeepSignal<{ z: number | undefined }> + >(); + expectTypeOf().toEqualTypeOf< + Signal + >(); }); it('succeeds when root state slices contain Function properties', () => { - const snippet1 = ` - const Store = signalStore( - withState({ - name: { x: { y: 'z' } }, - arguments: [1, 2, 3], - call: false, - }) - ); - `; - - const result1 = expectSnippet(snippet1); - result1.toInfer( - 'Store', - 'Type<{ name: DeepSignal<{ x: { y: string; }; }>; arguments: Signal; call: Signal; } & StateSource<{ name: { x: { y: string; }; }; arguments: number[]; call: boolean; }>>' + const Store1 = signalStore( + withState({ name: { x: { y: 'z' } }, arguments: [1, 2, 3], call: false }) ); - - const snippet2 = ` - const Store = signalStore( - withState({ - apply: 'apply', - bind: { foo: 'bar' }, - prototype: ['ngrx'], - }) - ); - `; - - const result2 = expectSnippet(snippet2); - result2.toInfer( - 'Store', - 'Type<{ apply: Signal; bind: DeepSignal<{ foo: string; }>; prototype: Signal; } & StateSource<{ apply: string; bind: { foo: string; }; prototype: string[]; }>>' - ); - - const snippet3 = ` - const Store = signalStore( - withState({ - length: 10, - caller: undefined, - }) - ); - `; - - const result3 = expectSnippet(snippet3); - result3.toInfer( - 'Store', - 'Type<{ length: Signal; caller: Signal; } & StateSource<{ length: number; caller: undefined; }>>' + expectTypeOf(Store1).toEqualTypeOf< + Type< + { + name: DeepSignal<{ x: { y: string } }>; + arguments: Signal; + call: Signal; + } & StateSource<{ + name: { x: { y: string } }; + arguments: number[]; + call: boolean; + }> + > + >(); + + const Store2 = signalStore( + withState({ apply: 'apply', bind: { foo: 'bar' }, prototype: ['ngrx'] }) ); + expectTypeOf(Store2).toEqualTypeOf< + Type< + { + apply: Signal; + bind: DeepSignal<{ foo: string }>; + prototype: Signal; + } & StateSource<{ + apply: string; + bind: { foo: string }; + prototype: string[]; + }> + > + >(); + + const Store3 = signalStore(withState({ length: 10, caller: undefined })); + expectTypeOf(Store3).toEqualTypeOf< + Type< + { length: Signal; caller: Signal } & StateSource<{ + length: number; + caller: undefined; + }> + > + >(); }); it('succeeds when nested state slices contain Function properties', () => { - const snippet1 = ` - type State = { x: { name?: string } }; - const Store = signalStore(withState({ x: { name: '' } })); - const store = new Store(); - const name = store.x.name; - `; - - const result1 = expectSnippet(snippet1); - result1.toInfer('name', 'Signal | undefined'); - - const snippet2 = ` - const Store = signalStore( - withState({ x: { length: { name: false }, baz: 1 } }) - ); - const store = new Store(); - const length = store.x.length; - const name = store.x.length.name; - `; - - const result2 = expectSnippet(snippet2); - result2.toInfer('length', 'DeepSignal<{ name: boolean; }>'); - result2.toInfer('name', 'Signal'); + type State1 = { x: { name?: string } }; + const Store1 = signalStore(withState({ x: { name: '' } })); + type S1 = InstanceType; + expectTypeOf().toEqualTypeOf< + Signal | undefined + >(); + + const Store2 = signalStore( + withState({ x: { length: { name: false }, baz: 1 } }) + ); + type S2 = InstanceType; + expectTypeOf().toEqualTypeOf< + DeepSignal<{ name: boolean }> + >(); + expectTypeOf().toEqualTypeOf>(); }); it('succeeds when nested state slices are optional', () => { - const snippet = ` - type State = { - bar: { baz?: number }; - x: { y?: { z: boolean } }; - }; - - const Store = signalStore(withState({ bar: {}, x: {} })); - - const store = new Store(); - const bar = store.bar; - const baz = store.bar.baz; - const x = store.x; - const y = store.x.y; - `; - - const result = expectSnippet(snippet); - result.toInfer( - 'store', - '{ bar: DeepSignal<{ baz?: number | undefined; }>; x: DeepSignal<{ y?: { z: boolean; } | undefined; }>; } & StateSource<{ bar: { baz?: number | undefined; }; x: { y?: { z: boolean; } | undefined; }; }>' - ); - result.toInfer('bar', 'DeepSignal<{ baz?: number | undefined; }>'); - result.toInfer('baz', 'Signal | undefined'); - result.toInfer('x', 'DeepSignal<{ y?: { z: boolean; } | undefined; }>'); - result.toInfer('y', 'Signal<{ z: boolean; } | undefined> | undefined'); + type State = { + bar: { baz?: number }; + x: { y?: { z: boolean } }; + }; + + const Store = signalStore(withState({ bar: {}, x: {} })); + type S = InstanceType; + expectTypeOf().toEqualTypeOf< + { + bar: DeepSignal<{ baz?: number | undefined }>; + x: DeepSignal<{ y?: { z: boolean } | undefined }>; + } & StateSource + >(); + expectTypeOf().toEqualTypeOf< + DeepSignal<{ baz?: number | undefined }> + >(); + expectTypeOf().toEqualTypeOf< + Signal | undefined + >(); + expectTypeOf().toEqualTypeOf< + DeepSignal<{ y?: { z: boolean } | undefined }> + >(); + expectTypeOf().toEqualTypeOf< + Signal<{ z: boolean } | undefined> | undefined + >(); }); it('succeeds when root state slices are optional', () => { - const snippet = ` - type State = { - foo?: { s: string }; - bar: number; - }; - - const Store = signalStore( - withState({ foo: { s: '' }, bar: 1 }) - ); - const store = new Store(); - const foo = store.foo; - `; - - const result = expectSnippet(snippet); - result.toInfer('foo', 'Signal<{ s: string; } | undefined> | undefined'); + type State = { foo?: { s: string }; bar: number }; + const Store = signalStore(withState({ foo: { s: '' }, bar: 1 })); + type S = InstanceType; + expectTypeOf().toEqualTypeOf< + Signal<{ s: string } | undefined> | undefined + >(); }); it('does not create deep signals when state is an unknown record', () => { - const snippet1 = ` - const Store = signalStore(withState<{ [key: string]: number }>({})); - const store = new Store(); - declare const storeKeys: keyof typeof store; - `; - - const result1 = expectSnippet(snippet1); - result1.toInfer('storeKeys', 'unique symbol'); - - const snippet2 = ` - const Store = signalStore( - withState<{ [key: number]: { bar: string } }>({}) - ); - const store = new Store(); - declare const storeKeys: keyof typeof store; - `; - - const result2 = expectSnippet(snippet2); - result2.toInfer('storeKeys', 'unique symbol'); - - const snippet3 = ` - const Store = signalStore( - withState>({ - x: { foo: true }, - y: 1, - }) - ); - const store = new Store(); - declare const storeKeys: keyof typeof store; - `; + const Store1 = signalStore(withState<{ [key: string]: number }>({})); + const store1 = null! as InstanceType; + expectTypeOf().toBeNever(); - const result3 = expectSnippet(snippet3); - result3.toInfer('storeKeys', 'unique symbol'); + const Store2 = signalStore( + withState<{ [key: number]: { bar: string } }>({}) + ); + const store2 = null! as InstanceType; + expectTypeOf().toBeNever(); + + const Store3 = signalStore( + withState>({ + x: { foo: true }, + y: 1, + }) + ); + const store3 = null! as InstanceType; + expectTypeOf().toBeNever(); }); it('fails when state is not an object', () => { - expectSnippet(`const Store = signalStore(withState(10));`).toFail(); - - expectSnippet(`const Store = signalStore(withState(''));`).toFail(); - - expectSnippet(`const Store = signalStore(withState(null));`).toFail(); - - expectSnippet(`const Store = signalStore(withState(true));`).toFail(); + // @ts-expect-error - Type 'number' is not assignable to type 'object' + signalStore(withState(10)); + // @ts-expect-error - Type 'string' is not assignable to type 'object' + signalStore(withState('')); + // @ts-expect-error - Type 'null' is not assignable to type 'object' + signalStore(withState(null)); + // @ts-expect-error - Type 'boolean' is not assignable to type 'object' + signalStore(withState(true)); }); it('exposes readonly state source when protectedState is not provided', () => { - const snippet = ` - const CounterStore1 = signalStore(withState({ count: 0 })); - const CounterStore2 = signalStore( - { providedIn: 'root' }, - withState({ count: 0 }) - ); - - const store1 = new CounterStore1(); - const state1 = getState(store1); - - const store2 = new CounterStore2(); - const state2 = getState(store2); - `; - - const result = expectSnippet(snippet); - result.toInfer( - 'store1', - '{ count: Signal; } & StateSource<{ count: number; }>' + const CounterStore1 = signalStore(withState({ count: 0 })); + const CounterStore2 = signalStore( + { providedIn: 'root' }, + withState({ count: 0 }) ); - result.toInfer('state1', '{ count: number; }'); - result.toInfer( - 'store2', - '{ count: Signal; } & StateSource<{ count: number; }>' - ); - result.toInfer('state2', '{ count: number; }'); - - expectSnippet(` - ${snippet} - patchState(store1, { count: 1 }); - `).toFail(); - expectSnippet(` - ${snippet} - patchState(store2, { count: 1 }); - `).toFail(); + type S1 = InstanceType; + type S2 = InstanceType; + expectTypeOf().toEqualTypeOf< + { count: Signal } & StateSource<{ count: number }> + >(); + expectTypeOf< + S1 extends StateSource ? St : never + >().toEqualTypeOf<{ count: number }>(); + expectTypeOf().toEqualTypeOf< + { count: Signal } & StateSource<{ count: number }> + >(); + expectTypeOf< + S2 extends StateSource ? St : never + >().toEqualTypeOf<{ count: number }>(); + // The arrow function is never invoked, so the body is type-checked + // at compile time but not executed at runtime by the test runner. + const store1 = null! as S1; + const store2 = null! as S2; + // @ts-expect-error - readonly state source cannot be patched from outside + const _patchStore1 = () => patchState(store1, { count: 1 }); + // @ts-expect-error - readonly state source cannot be patched from outside + const _patchStore2 = () => patchState(store2, { count: 1 }); }); it('exposes readonly state source when protectedState is true', () => { - const snippet = ` - const CounterStore1 = signalStore( - { protectedState: true }, - withState({ count: 0 }) - ); - const CounterStore2 = signalStore( - { providedIn: 'root', protectedState: true }, - withState({ count: 0 }) - ); - - const store1 = new CounterStore1(); - const state1 = getState(store1); - - const store2 = new CounterStore2(); - const state2 = getState(store2); - `; - - const result = expectSnippet(snippet); - result.toInfer( - 'store1', - '{ count: Signal; } & StateSource<{ count: number; }>' + const CounterStore1 = signalStore( + { protectedState: true }, + withState({ count: 0 }) ); - result.toInfer('state1', '{ count: number; }'); - result.toInfer( - 'store2', - '{ count: Signal; } & StateSource<{ count: number; }>' + const CounterStore2 = signalStore( + { providedIn: 'root', protectedState: true }, + withState({ count: 0 }) ); - result.toInfer('state2', '{ count: number; }'); - - expectSnippet(` - ${snippet} - patchState(store1, { count: 10 }); - `).toFail(); - expectSnippet(` - ${snippet} - patchState(store2, { count: 10 }); - `).toFail(); + type S1 = InstanceType; + type S2 = InstanceType; + expectTypeOf().toEqualTypeOf< + { count: Signal } & StateSource<{ count: number }> + >(); + expectTypeOf< + S1 extends StateSource ? St : never + >().toEqualTypeOf<{ count: number }>(); + expectTypeOf().toEqualTypeOf< + { count: Signal } & StateSource<{ count: number }> + >(); + expectTypeOf< + S2 extends StateSource ? St : never + >().toEqualTypeOf<{ count: number }>(); + const store1 = null! as S1; + const store2 = null! as S2; + // @ts-expect-error - readonly state source cannot be patched from outside + const _patchStore1 = () => patchState(store1, { count: 10 }); + // @ts-expect-error - readonly state source cannot be patched from outside + const _patchStore2 = () => patchState(store2, { count: 10 }); }); it('exposes writable state source when protectedState is false', () => { - const snippet = ` - const CounterStore1 = signalStore( - { protectedState: false }, - withState({ count: 0 }) - ); - const CounterStore2 = signalStore( - { providedIn: 'root', protectedState: false }, - withState({ count: 0 }) - ); - - const store1 = new CounterStore1(); - const state1 = getState(store1); - - const store2 = new CounterStore2(); - const state2 = getState(store2); - `; - - const result = expectSnippet(snippet); - result.toInfer( - 'store1', - '{ count: Signal; } & WritableStateSource<{ count: number; }>' + const CounterStore1 = signalStore( + { protectedState: false }, + withState({ count: 0 }) ); - result.toInfer('state1', '{ count: number; }'); - result.toInfer( - 'store2', - '{ count: Signal; } & WritableStateSource<{ count: number; }>' + const CounterStore2 = signalStore( + { providedIn: 'root', protectedState: false }, + withState({ count: 0 }) ); - result.toInfer('state2', '{ count: number; }'); - expectSnippet(` - ${snippet} - patchState(store1, { count: 100 }); - patchState(store2, { count: 100 }); - `).toSucceed(); + type S1 = InstanceType; + type S2 = InstanceType; + expectTypeOf().toEqualTypeOf< + { count: Signal } & WritableStateSource<{ count: number }> + >(); + expectTypeOf< + S1 extends StateSource ? St : never + >().toEqualTypeOf<{ count: number }>(); + expectTypeOf().toEqualTypeOf< + { count: Signal } & WritableStateSource<{ count: number }> + >(); + expectTypeOf< + S2 extends StateSource ? St : never + >().toEqualTypeOf<{ count: number }>(); + const store1 = null! as S1; + const store2 = null! as S2; + const _patchStore1 = () => patchState(store1, { count: 100 }); + const _patchStore2 = () => patchState(store2, { count: 100 }); }); it('patches state via sequence of partial state objects and updater functions', () => { - expectSnippet(` - const Store = signalStore( - withState({ ngrx: 'signals' }), - withState({ user: { age: 10, first: 'John' } }), - withMethods((store) => { - patchState( - store, - (state) => ({ user: { ...state.user, first: 'Peter' } }), - { ngrx: 'rocks' } - ); - - return {}; - }), - withState({ flags: [true, false, true] }), - withMethods(({ ngrx, flags, ...store }) => { - patchState( - store, - { ngrx: 'rocks' }, - (state) => ({ flags: [...state.flags, true] }) - ); - - patchState( - store, - { flags: [true] }, - (state) => ({ user: { ...state.user, age: state.user.age + 1 } }), - { ngrx: 'store' } - ); - - return {}; - }) - ); - `).toSucceed(); + signalStore( + withState({ ngrx: 'signals' }), + withState({ user: { age: 10, first: 'John' } }), + withMethods((store) => { + patchState( + store, + (state) => ({ user: { ...state.user, first: 'Peter' } }), + { ngrx: 'rocks' } + ); + return {}; + }), + withState({ flags: [true, false, true] }), + withMethods(({ ngrx, flags, ...store }) => { + patchState(store, { ngrx: 'rocks' }, (state) => ({ + flags: [...state.flags, true], + })); + patchState( + store, + { flags: [true] }, + (state) => ({ user: { ...state.user, age: state.user.age + 1 } }), + { ngrx: 'store' } + ); + return {}; + }) + ); }); it('fails when state is patched with a non-record', () => { - expectSnippet(` - const Store = signalStore( - { protectedState: false }, - withState({ foo: 'bar' }) - ); - - const store = new Store(); - patchState(store, 10); - `).toFail(); - - expectSnippet(` - const Store = signalStore( - { protectedState: false }, - withState({ foo: 'bar' }) - ); - - const store = new Store(); - patchState(store, undefined); - `).toFail(); - - expectSnippet(` - const Store = signalStore( - { protectedState: false }, - withState({ foo: 'bar' }) - ); - - const store = new Store(); - patchState(store, [1, 2, 3]); - `).toFail(); + const Store = signalStore( + { protectedState: false }, + withState({ foo: 'bar' }) + ); + type S = InstanceType; + const store = null! as S; + // @ts-expect-error - 'number' is not assignable to 'Partial> | PartialStateUpdater>' + const _patchWithNumber = () => patchState(store, 10); + // @ts-expect-error - 'undefined' is not assignable to 'Partial> | PartialStateUpdater>' + const _patchWithUndefined = () => patchState(store, undefined); + // @ts-expect-error - 'number[]' is not assignable to 'Partial> | PartialStateUpdater>' + const _patchWithArray = () => patchState(store, [1, 2, 3]); }); it('fails when state is patched with a wrong record', () => { - expectSnippet(` + { const Store = signalStore( { protectedState: false }, withState({ foo: 'bar' }) ); - - const store = new Store(); - patchState(store, { foo: 10 }); - `).toFail(/Type 'number' is not assignable to type 'string'/); - - expectSnippet(` - const Store = signalStore( + type S = InstanceType; + const store = null! as S; + // @ts-expect-error - Type 'number' is not assignable to type 'string' + const _patchWithWrongType = () => patchState(store, { foo: 10 }); + } + { + signalStore( withState({ foo: 'bar' }), withMethods((store) => { + // @ts-expect-error - Type 'number' is not assignable to type 'string' patchState(store, { foo: 10 }); return {}; }) ); - `).toFail(/Type 'number' is not assignable to type 'string'/); - - expectSnippet(` - const Store = signalStore( + } + { + signalStore( withState({ foo: 'bar' }), withMethods(({ foo, ...store }) => { + // @ts-expect-error - Type 'number' is not assignable to type 'string' patchState(store, { foo: 10 }); return {}; }) ); - `).toFail(/Type 'number' is not assignable to type 'string'/); + } }); it('fails when state is patched with a wrong updater function', () => { - expectSnippet(` + { const Store = signalStore( { protectedState: false }, withState({ user: { first: 'John', age: 20 } }) ); - - const store = new Store(); - patchState(store, (state) => ({ user: { ...state.user, age: '30' } })); - `).toFail(/Type 'string' is not assignable to type 'number'/); - - expectSnippet(` - const Store = signalStore( + type S = InstanceType; + const store = null! as S; + // @ts-expect-error - Type 'string' is not assignable to type 'number' + const _patchWithWrongUpdater = () => + patchState(store, (state) => ({ user: { ...state.user, age: '30' } })); + } + { + signalStore( withState({ user: { first: 'John', age: 20 } }), withMethods((store) => { - patchState(store, (state) => ({ user: { ...state.user, age: '30' } })); + // @ts-expect-error - Type 'string' is not assignable to type 'number' + patchState(store, (state) => ({ + user: { ...state.user, age: '30' }, + })); return {}; }) ); - `).toFail(/Type 'string' is not assignable to type 'number'/); - - expectSnippet(` - const Store = signalStore( + } + { + signalStore( withState({ user: { first: 'John', age: 20 } }), withMethods(({ user, ...store }) => { - patchState(store, (state) => ({ user: { ...state.user, first: 10 } })); + // @ts-expect-error - Type 'number' is not assignable to type 'string' + patchState(store, (state) => ({ + user: { ...state.user, first: 10 }, + })); return {}; }) ); - `).toFail(/Type 'number' is not assignable to type 'string'/); + } }); it('allows injecting store using the `inject` function', () => { - const snippet = ` - const Store = signalStore( - withState({ ngrx: 'rocks', x: { y: 'z' } }), - withComputed(() => ({ signals: computed(() => [1, 2, 3]) })), - withMethods(() => ({ - mgmt(arg: boolean): number { - return 1; - } - })) - ); + const Store = signalStore( + withState({ ngrx: 'rocks', x: { y: 'z' } }), + withComputed(() => ({ signals: computed(() => [1, 2, 3]) })), + withMethods(() => ({ + mgmt(_arg: boolean): number { + return 1; + }, + })) + ); - const store = inject(Store); - `; + const store = null! as InstanceType; - const result = expectSnippet(snippet); - result.toInfer( - 'store', - '{ ngrx: Signal; x: DeepSignal<{ y: string; }>; signals: Signal; mgmt: (arg: boolean) => number; } & StateSource<{ ngrx: string; x: { y: string; }; }>' - ); + expectTypeOf(store).toEqualTypeOf< + { + ngrx: Signal; + x: DeepSignal<{ y: string }>; + signals: Signal; + mgmt: (arg: boolean) => number; + } & StateSource<{ ngrx: string; x: { y: string } }> + >(); }); it('allows using store via constructor-based dependency injection', () => { - const snippet = ` - const Store = signalStore( - withState({ foo: 10 }), - withComputed(({ foo }) => ({ bar: computed(() => foo() + '1') })), - withMethods(() => ({ - baz(x: number): void {} - })) - ); - - type Store = InstanceType; - - class Component { - constructor (readonly store: Store) {} - } + const Store = signalStore( + withState({ foo: 10 }), + withComputed(({ foo }) => ({ bar: computed(() => foo() + '1') })), + withMethods(() => ({ + baz(_x: number): void {}, + })) + ); - const component = new Component(new Store()); - const store = component.store; - `; + type StoreInstance = InstanceType; + const store = null! as StoreInstance; - const result = expectSnippet(snippet); - result.toInfer( - 'store', - '{ foo: Signal; bar: Signal; baz: (x: number) => void; } & StateSource<{ foo: number; }>' - ); + expectTypeOf(store).toEqualTypeOf< + { + foo: Signal; + bar: Signal; + baz: (x: number) => void; + } & StateSource<{ foo: number }> + >(); }); it('correctly infers the type of methods with generics', () => { - const snippet = ` - const Store = signalStore( - withMethods(() => ({ - log(str: Str) { - console.log(str); - }, - })) - ); - - const store = inject(Store); - `; + const Store = signalStore( + withMethods(() => ({ + log(str: Str) { + console.log(str); + }, + })) + ); - expectSnippet(snippet + `store.log('ngrx');`).toSucceed(); - expectSnippet(snippet + `store.log(10);`).toFail(); + type S = InstanceType; + const store = null! as S; + const _logString = () => store.log('ngrx'); + // @ts-expect-error - number is not assignable to string + const _logNumber = () => store.log(10); }); it('omits private store members from the public instance', () => { - const snippet = ` - const CounterStore = signalStore( - withState({ count1: 0, _count2: 0 }), - withComputed(({ count1, _count2 }) => ({ - _doubleCount1: computed(() => count1() * 2), - doubleCount2: computed(() => _count2() * 2), - })), - withMethods(() => ({ - increment1() {}, - _increment2() {}, - })), - withHooks({ - onInit({ increment1, _increment2 }) { - increment1(); - _increment2(); - }, - }) - ); + const CounterStore = signalStore( + withState({ count1: 0, _count2: 0 }), + withComputed(({ count1, _count2 }) => ({ + _doubleCount1: computed(() => count1() * 2), + doubleCount2: computed(() => _count2() * 2), + })), + withMethods(() => ({ + increment1() {}, + _increment2() {}, + })), + withHooks({ + onInit({ increment1, _increment2 }) { + increment1(); + _increment2(); + }, + }) + ); - const store = new CounterStore(); - `; + const store = null! as InstanceType; - const result = expectSnippet(snippet); - result.toInfer( - 'store', - '{ count1: Signal; doubleCount2: Signal; increment1: () => void; } & StateSource<{ count1: number; }>' - ); + expectTypeOf(store).toEqualTypeOf< + { + count1: Signal; + doubleCount2: Signal; + increment1: () => void; + } & StateSource<{ count1: number }> + >(); }); it('prevents private state slices from being updated from the outside', () => { - const snippet = ` - const CounterStore = signalStore( - { protectedState: false }, - withState({ count1: 0, _count2: 0 }), - ); - - const store = new CounterStore(); - `; - - expectSnippet(` - ${snippet} - patchState(store, { count1: 1 }); - `).toSucceed(); - - expectSnippet(` - ${snippet} + const CounterStore = signalStore( + { protectedState: false }, + withState({ count1: 0, _count2: 0 }) + ); + type S = InstanceType; + const store = null! as S; + const _patchPublicState = () => patchState(store, { count1: 1 }); + // @ts-expect-error - '_count2' does not exist in type + const _patchPrivateState = () => patchState(store, { count1: 1, _count2: 1 }); - `).toFail(/'_count2' does not exist in type/); }); describe('custom features', () => { - const baseSnippet = ` - function withX() { - return signalStoreFeature(withState({ x: 1 })); - } - - type Y = { a: string; b: number }; - const initialY: Y = { a: '', b: 5 }; - - function withY<_>() { - return signalStoreFeature( - { - state: type<{ q1: string }>(), - props: type<{ sig: Signal }>(), + function withX() { + return signalStoreFeature(withState({ x: 1 })); + } + + type Y = { a: string; b: number }; + const initialY: Y = { a: '', b: 5 }; + + function withY<_>() { + return signalStoreFeature( + { + state: type<{ q1: string }>(), + props: type<{ sig: Signal }>(), + }, + withState({ y: initialY }), + withComputed(() => ({ sigY: computed(() => 'sigY') })), + withHooks({ + onInit({ q1, y, sigY, ...store }) { + patchState(store, { q1: '', y: { a: 'a', b: 2 } }); }, - withState({ y: initialY }), - withComputed(() => ({ sigY: computed(() => 'sigY') })), - withHooks({ - onInit({ q1, y, sigY, ...store }) { - patchState(store, { q1: '', y: { a: 'a', b: 2 } }); - }, - }) - ); - } + }) + ); + } - function withZ<_>() { - return signalStoreFeature( - { methods: type<{ f: () => void }>() }, - withMethods(({ f }) => ({ - z() { - f(); - } - })) - ); - } - `; + function withZ<_>() { + return signalStoreFeature( + { methods: type<{ f: () => void }>() }, + withMethods(({ f }) => ({ + z() { + f(); + }, + })) + ); + } it('combines custom features', () => { - expectSnippet(` + { function withFoo() { return withState({ foo: 'foo' }); } @@ -856,55 +726,43 @@ describe('signalStore', () => { withState({ count: 0 }), withBar() ); - `).toSucceed(); - - expectSnippet(` - ${baseSnippet} - - const Store = signalStore( - withMethods((store) => ({ - f() {}, - g() {}, - })), - withComputed(() => ({ sig: computed(() => false) })), - withState({ q1: 'q1', q2: 'q2' }), - withX(), - withY(), - withZ() - ); - `).toSucceed(); - - expectSnippet(` - ${baseSnippet} + } - const feature = signalStoreFeature( - { props: type<{ sig: Signal }>() }, - withX(), - withState({ q1: 'q1' }), - withY(), - withMethods((store) => ({ - f() { - patchState(store, { x: 1, q1: 'xyz', y: { a: '', b: 0 } }); - }, - })), - withZ() - ); - `).toSucceed(); + signalStore( + withMethods((_store) => ({ f() {}, g() {} })), + withComputed(() => ({ sig: computed(() => false) })), + withState({ q1: 'q1', q2: 'q2' }), + withX(), + withY(), + withZ() + ); + + signalStoreFeature( + { props: type<{ sig: Signal }>() }, + withX(), + withState({ q1: 'q1' }), + withY(), + withMethods((store) => ({ + f() { + patchState(store, { x: 1, q1: 'xyz', y: { a: '', b: 0 } }); + }, + })), + withZ() + ); }); it('fails when custom feature is used with wrong input', () => { - expectSnippet( - `${baseSnippet} const Store = signalStore(withY());` - ).toFail(); - - expectSnippet( - `${baseSnippet} const withY2 = () => signalStoreFeature(withY());` - ).toFail(); - - expectSnippet(` - ${baseSnippet} - - const Store = signalStore( + { + // @ts-expect-error - withY requires state: { q1: string } and props: { sig: Signal } + signalStore(withY()); + } + { + // @ts-expect-error - withY requires state: { q1: string } and props: { sig: Signal } + signalStoreFeature(withY()); + } + { + // @ts-expect-error - q1 type mismatch: number vs string required by withY + signalStore( withState({ q1: 1, q2: 'q2' }), withComputed(() => ({ sig: computed(() => false) })), withX(), @@ -916,12 +774,10 @@ describe('signalStore', () => { }, })) ); - `).toFail(); - - expectSnippet(` - ${baseSnippet} - - const feature = signalStoreFeature( + } + { + // @ts-expect-error - sig type mismatch: Signal vs Signal required by withY + signalStoreFeature( { props: type<{ sig: Signal }>() }, withX(), withState({ q1: 'q1' }), @@ -932,12 +788,10 @@ describe('signalStore', () => { }, })) ); - `).toFail(); - - expectSnippet(` - ${baseSnippet} - - const Store = signalStore( + } + // The following combination succeeds: correct q1 type, sig type, and all required methods + { + signalStore( withState({ q1: 'q1', q2: 'q2' }), withComputed(() => ({ sig: computed(() => false) })), withX(), @@ -948,58 +802,47 @@ describe('signalStore', () => { g: (str: string) => console.log(str), })), withY(), - withZ(), + withZ() ); - const feature = signalStoreFeature( + signalStoreFeature( { props: type<{ sig: Signal }>(), - methods: type<{ f(): void; g(arg: string): string; }>(), + methods: type<{ f(): void; g(arg: string): string }>(), }, withX(), withZ(), withState({ q1: 'q1' }), - withY(), + withY() ); - `).toSucceed(); + } }); }); describe('custom features with generics', () => { - const baseSnippet = ` - function withSelectedEntity() { - return signalStoreFeature( - type<{ - state: { - entities: Entity[]; - }; - }>(), - withState({ selectedEntity: null as Entity | null }), - withComputed(({ selectedEntity, entities }) => ({ - selectedEntity2: computed(() => - selectedEntity() - ? entities().find((e) => e === selectedEntity()) - : undefined - ), - })) - ); - } - - function withLoadEntities() { - return signalStoreFeature( - type<{ - state: { - entities: Entity[]; - selectedEntity: Entity | null; - }; - props: { - selectedEntity2: Signal; - }; - methods: { - logEntity: (entity: Entity) => void; - }; - }>(), - withMethods(({ entities, selectedEntity, selectedEntity2, logEntity }) => { + function withSelectedEntity() { + return signalStoreFeature( + type<{ state: { entities: Entity[] } }>(), + withState({ selectedEntity: null as Entity | null }), + withComputed(({ selectedEntity, entities }) => ({ + selectedEntity2: computed(() => + selectedEntity() + ? entities().find((e) => e === selectedEntity()) + : undefined + ), + })) + ); + } + + function withLoadEntities() { + return signalStoreFeature( + type<{ + state: { entities: Entity[]; selectedEntity: Entity | null }; + props: { selectedEntity2: Signal }; + methods: { logEntity: (entity: Entity) => void }; + }>(), + withMethods( + ({ entities, selectedEntity, selectedEntity2, logEntity }) => { const e: Signal = entities; const se: Signal = selectedEntity; const se2: Signal = selectedEntity2; @@ -1010,115 +853,81 @@ describe('signalStore', () => { return Promise.resolve([]); }, }; - }) - ); - } + } + ) + ); + } - type User = { - id: string; - firstName: string; - lastName: string; - }; - `; + type User = { id: string; firstName: string; lastName: string }; it('combines custom features with generics', () => { - const snippet = ` - ${baseSnippet} - - const Store = signalStore( - withState({ entities: [] as User[] }), - withSelectedEntity(), - withMethods(() => ({ - logEntity(user: User) { - console.log(user); - }, - })), - withLoadEntities() - ); - - const store = new Store(); - const selectedEntity = store.selectedEntity; - const selectedEntity2 = store.selectedEntity2; - const loadEntities = store.loadEntities; - - const feature = signalStoreFeature( - { - state: type<{ - entities: User[]; - }>(), - methods: type<{ - logEntity: (entity: User) => void; - }>(), + const Store = signalStore( + withState({ entities: [] as User[] }), + withSelectedEntity(), + withMethods(() => ({ + logEntity(user: User) { + console.log(user); }, - withSelectedEntity(), - withLoadEntities() - ); - `; + })), + withLoadEntities() + ); + + type S = InstanceType; + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf< + Signal + >(); + expectTypeOf().toEqualTypeOf<() => Promise>(); - const result = expectSnippet(snippet); - result.toInfer('selectedEntity', 'Signal'); - result.toInfer('selectedEntity2', 'Signal'); - result.toInfer('loadEntities', '() => Promise'); + signalStoreFeature( + { + state: type<{ entities: User[] }>(), + methods: type<{ logEntity: (entity: User) => void }>(), + }, + withSelectedEntity(), + withLoadEntities() + ); }); it('fails when custom feature with generics is used with wrong input', () => { - expectSnippet(` - ${baseSnippet} - - const Store = signalStore( + { + // @ts-expect-error - missing logEntity method + signalStore( withState({ entities: [] as User[] }), withSelectedEntity(), withLoadEntities() ); - `).toFail(); - - expectSnippet(` - ${baseSnippet} - - const feature = signalStoreFeature( + } + { + // @ts-expect-error - logEntity expects User, not number + signalStoreFeature( { - state: type<{ - entities: User[]; - }>(), - methods: type<{ - logEntity: (entity: number) => void; - }>(), + state: type<{ entities: User[] }>(), + methods: type<{ logEntity: (entity: number) => void }>(), }, withSelectedEntity(), withLoadEntities() ); - `).toFail(); - - expectSnippet(` - ${baseSnippet} - - const feature = signalStoreFeature( - { - state: type<{ - entities: User[]; - }>(), - }, + } + { + // @ts-expect-error - missing logEntity method in feature + signalStoreFeature( + { state: type<{ entities: User[] }>() }, withSelectedEntity(), withLoadEntities() ); - `).toFail(); - - expectSnippet(` - ${baseSnippet} - - const feature = signalStoreFeature( + } + { + // @ts-expect-error - entities type mismatch: boolean vs User[] + signalStoreFeature( { - state: type<{ - entities: boolean; - }>(), - methods: type<{ - logEntity: (entity: User) => void; - }>(), + state: type<{ entities: boolean }>(), + methods: type<{ logEntity: (entity: User) => void }>(), }, withSelectedEntity(), withLoadEntities() ); - `).toFail(); + } }); }); -}, 8_000); +}); diff --git a/modules/signals/spec/types/with-computed.types.spec.ts b/modules/signals/spec/types/with-computed.types.spec.ts index 61670dd882..1572eddab8 100644 --- a/modules/signals/spec/types/with-computed.types.spec.ts +++ b/modules/signals/spec/types/with-computed.types.spec.ts @@ -1,108 +1,72 @@ -import { expecter } from 'ts-snippet'; -import { compilerOptions } from './helpers'; +import { expectTypeOf } from 'vitest'; +import { signal, Signal, WritableSignal } from '@angular/core'; +import { + deepComputed, + DeepSignal, + patchState, + signalStore, + withComputed, + withMethods, + withProps, + withState, +} from '@ngrx/signals'; describe('withComputed', () => { - const expectSnippet = expecter( - (code) => ` - import { - deepComputed, - patchState, - signalStore, - withComputed, - withMethods, - withProps, - withState, - } from '@ngrx/signals'; - import { TestBed } from '@angular/core/testing'; - import { signal } from '@angular/core'; - - ${code} - `, - compilerOptions() - ); - it('has access to props, state signals and methods', () => { - const snippet = ` - signalStore( - withState({ - a: 1, - }), - withProps(() => { - return { - b: 2, - }; - }), - withMethods(({ a, b }) => ({ - sum: () => a() + b, - })), - withComputed(({ a, b, sum }) => ({ - prettySum: () => \`Sum: \${a()} + \${b} = \${sum()}\`, - })) - ); - `; - - expectSnippet(snippet).toSucceed(); + signalStore( + withState({ a: 1 }), + withProps(() => ({ b: 2 })), + withMethods(({ a, b }) => ({ + sum: () => a() + b, + })), + withComputed(({ a, b, sum }) => ({ + prettySum: () => `Sum: ${a()} + ${b} = ${sum()}`, + })) + ); }); it('has no access to the state source', () => { - const snippet = ` - signalStore( - withState({ - a: 1, - }), - withComputed((store) => ({ - prettySum: () => { - patchState(store, { a: 2 }); - return store.a(); - }, - })) - ); - `; - - expectSnippet(snippet).toFail( - /not assignable to parameter of type 'WritableStateSource'/ + signalStore( + withState({ a: 1 }), + withComputed((store) => ({ + prettySum: () => { + // @ts-expect-error - store is not assignable to parameter of type 'WritableStateSource' + patchState(store, { a: 2 }); + return store.a(); + }, + })) ); }); it('creates a Signal automatically', () => { - const snippet = ` - const Store = signalStore( - withComputed(() => ({ - user: () => ({ firstName: 'John', lastName: 'Doe' }) - })) - ); - - const store = TestBed.inject(Store); - const user = store.user; - `; + const Store = signalStore( + withComputed(() => ({ + user: () => ({ firstName: 'John', lastName: 'Doe' }), + })) + ); - const result = expectSnippet(snippet); - result.toInfer('user', 'Signal<{ firstName: string; lastName: string; }>'); + type S = InstanceType; + expectTypeOf().toEqualTypeOf< + Signal<{ firstName: string; lastName: string }> + >(); }); it('keeps a WritableSignal intact, if passed', () => { - const snippet = ` - const user = signal({ firstName: 'John', lastName: 'Doe' }); - - const Store = signalStore( - withComputed(() => ({ - user, - })) - ); + const user = signal({ firstName: 'John', lastName: 'Doe' }); - const store = TestBed.inject(Store); - const userSignal = store.user; - `; - - const result = expectSnippet(snippet); - result.toInfer( - 'userSignal', - 'WritableSignal<{ firstName: string; lastName: string; }>' + const Store = signalStore( + withComputed(() => ({ + user, + })) ); + + type S = InstanceType; + expectTypeOf().toEqualTypeOf< + WritableSignal<{ firstName: string; lastName: string }> + >(); }); it('keeps a DeepSignal intact, if passed', () => { - const snippet = ` const user = deepComputed( signal({ name: 'John Doe', @@ -119,14 +83,9 @@ describe('withComputed', () => { })) ); - const store = TestBed.inject(Store); - const userSignal = store.user; - `; - - const result = expectSnippet(snippet); - result.toInfer( - 'userSignal', - 'DeepSignal<{ name: string; address: { street: string; city: string; }; }>' - ); + type S = InstanceType; + expectTypeOf().toEqualTypeOf< + DeepSignal<{ name: string; address: { street: string; city: string } }> + >(); }); -}, 8_000); +}); diff --git a/modules/signals/spec/types/with-linked-state.types.spec.ts b/modules/signals/spec/types/with-linked-state.types.spec.ts index 9430ed9270..c776fa61fa 100644 --- a/modules/signals/spec/types/with-linked-state.types.spec.ts +++ b/modules/signals/spec/types/with-linked-state.types.spec.ts @@ -1,172 +1,127 @@ -import { expecter } from 'ts-snippet'; -import { compilerOptions } from './helpers'; +import { expectTypeOf } from 'vitest'; +import { linkedSignal, Signal, signal } from '@angular/core'; +import { + DeepSignal, + patchState, + signalStore, + withLinkedState, + withMethods, + withState, +} from '@ngrx/signals'; describe('withLinkedState', () => { - const expectSnippet = expecter( - (code) => ` - import { computed, inject, linkedSignal, Signal, signal } from '@angular/core'; - import { - patchState, - signalStore, - withState, - withLinkedState, - withMethods - } from '@ngrx/signals'; - - ${code} - `, - compilerOptions() - ); - it('does not have access to methods', () => { - const snippet = ` - signalStore( - withMethods(() => ({ - foo: () => 'bar', - })), - withLinkedState(({ foo }) => ({ value: foo() })) - ); - `; - - expectSnippet(snippet).toFail(/Property 'foo' does not exist on type '{}'/); + signalStore( + withMethods(() => ({ + foo: () => 'bar', + })), + withLinkedState( + // @ts-expect-error - Property 'foo' does not exist on type '{}' + ({ foo }: { foo: unknown }) => ({ value: foo }) + ) + ); }); it('does not have access to STATE_SOURCE', () => { - const snippet = ` - signalStore( - withState({ foo: 'bar' }), - withLinkedState((store) => { - patchState(store, { foo: 'baz' }); - return { bar: 'foo' }; - }) - ) - `; - - expectSnippet(snippet).toFail( - /is not assignable to parameter of type 'WritableStateSource'./ + signalStore( + withState({ foo: 'bar' }), + withLinkedState((store) => { + patchState( + // @ts-expect-error - store is not assignable to parameter of type 'WritableStateSource' + store, + { foo: 'baz' } + ); + return { bar: () => 'baz' }; + }) ); }); it('cannot return a primitive value', () => { - const snippet = ` - signalStore( - withLinkedState(() => ({ foo: 'bar' })) - ) - `; - - expectSnippet(snippet).toFail( - /Type 'string' is not assignable to type 'WritableSignal | (() => unknown)'./ + signalStore( + withLinkedState(() => ({ + // @ts-expect-error - Type 'string' is not assignable to type 'WritableSignal | (() => unknown)' + foo: 'bar', + })) ); }); it('adds state slice with computation function', () => { - const snippet = ` - const UserStore = signalStore( - { providedIn: 'root' }, - withLinkedState(() => ({ firstname: () => 'John', lastname: () => 'Doe' })) - ); - - const userStore = new UserStore(); - - const firstname = userStore.firstname; - const lastname = userStore.lastname; - `; + const UserStore = signalStore( + { providedIn: 'root' }, + withLinkedState(() => ({ + firstname: () => 'John', + lastname: () => 'Doe', + })) + ); - const result = expectSnippet(snippet); - result.toInfer('firstname', 'Signal'); - result.toInfer('lastname', 'Signal'); + type S = InstanceType; + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf>(); }); it('adds state slice with explicit linkedSignal', () => { - const snippet = ` - const UserStore = signalStore( - { providedIn: 'root' }, - withLinkedState(() => ({ - firstname: linkedSignal(() => 'John'), - lastname: linkedSignal(() => 'Doe') - })) - ); - - const userStore = new UserStore(); - - const firstname = userStore.firstname; - const lastname = userStore.lastname; - `; + const UserStore = signalStore( + { providedIn: 'root' }, + withLinkedState(() => ({ + firstname: linkedSignal(() => 'John'), + lastname: linkedSignal(() => 'Doe'), + })) + ); - const result = expectSnippet(snippet); - result.toInfer('firstname', 'Signal'); - result.toInfer('lastname', 'Signal'); + type S = InstanceType; + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf>(); }); it('creates deep signals with computation functions', () => { - const snippet = ` - const UserStore = signalStore( - { providedIn: 'root' }, - withLinkedState(() => ({ - user: () => ({ id: 1, name: 'John Doe' }), - location: () => ({ city: 'Berlin', country: 'Germany' }), - })) - ); - - const userStore = new UserStore(); - - const location = userStore.location; - const user = userStore.user; - `; - - const result = expectSnippet(snippet); - result.toInfer( - 'location', - 'DeepSignal<{ city: string; country: string; }>' + const UserStore = signalStore( + { providedIn: 'root' }, + withLinkedState(() => ({ + user: () => ({ id: 1, name: 'John Doe' }), + location: () => ({ city: 'Berlin', country: 'Germany' }), + })) ); - result.toInfer('user', 'DeepSignal<{ id: number; name: string; }>'); + + type S = InstanceType; + expectTypeOf().toEqualTypeOf< + DeepSignal<{ city: string; country: string }> + >(); + expectTypeOf().toEqualTypeOf< + DeepSignal<{ id: number; name: string }> + >(); }); it('creates deep signals with explicit linked signals', () => { - const snippet = ` - const UserStore = signalStore( - { providedIn: 'root' }, - withLinkedState(() => ({ - user: linkedSignal(() => ({ id: 1, name: 'John Doe' })), - location: linkedSignal(() => ({ city: 'Berlin', country: 'Germany' })), - })) - ); - - const userStore = new UserStore(); - - const location = userStore.location; - const user = userStore.user; - `; - - const result = expectSnippet(snippet); - result.toInfer( - 'location', - 'DeepSignal<{ city: string; country: string; }>' + const UserStore = signalStore( + { providedIn: 'root' }, + withLinkedState(() => ({ + user: linkedSignal(() => ({ id: 1, name: 'John Doe' })), + location: linkedSignal(() => ({ city: 'Berlin', country: 'Germany' })), + })) ); - result.toInfer('user', 'DeepSignal<{ id: number; name: string; }>'); + + type S = InstanceType; + expectTypeOf().toEqualTypeOf< + DeepSignal<{ city: string; country: string }> + >(); + expectTypeOf().toEqualTypeOf< + DeepSignal<{ id: number; name: string }> + >(); }); it('infers the types for a mixed setting', () => { - const snippet = ` - const Store = signalStore( - withState({ foo: 'bar' }), - withLinkedState(({ foo }) => ({ - bar: () => foo(), - baz: linkedSignal(() => foo()), - qux: signal({ x: 1 }), - })) - ); - - const store = new Store(); - - const bar = store.bar; - const baz = store.baz; - const qux = store.qux; - `; + const Store = signalStore( + withState({ foo: 'bar' }), + withLinkedState(({ foo }) => ({ + bar: () => foo(), + baz: linkedSignal(() => foo()), + qux: signal({ x: 1 }), + })) + ); - const result = expectSnippet(snippet); - result.toInfer('bar', 'Signal'); - result.toInfer('baz', 'Signal'); - result.toInfer('qux', 'DeepSignal<{ x: number; }>'); + type S = InstanceType; + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf>(); }); -}, 8_000); +});