Skip to content

Commit c0dac48

Browse files
feature(signals): Enhance type safety
1 parent a23a0a1 commit c0dac48

File tree

5 files changed

+50
-37
lines changed

5 files changed

+50
-37
lines changed

modules/signals/entities/src/helpers.ts

+22-11
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
DidMutate,
33
EntityChanges,
44
EntityId,
5+
EntityMap,
56
EntityPredicate,
67
EntityState,
78
SelectEntityId,
@@ -10,6 +11,11 @@ import {
1011
declare const ngDevMode: unknown;
1112
const defaultSelectId: SelectEntityId<{ id: EntityId }> = (entity) => entity.id;
1213

14+
type EntityStateMutable<Entity = any> = {
15+
entityMap: Record<EntityId, Entity>;
16+
ids: EntityId[];
17+
};
18+
1319
export function getEntityIdSelector(config?: {
1420
selectId?: SelectEntityId<any>;
1521
}): SelectEntityId<any> {
@@ -79,8 +85,8 @@ export function addEntityMutably(
7985
return DidMutate.None;
8086
}
8187

82-
state.entityMap[id] = entity;
83-
state.ids.push(id);
88+
(state as EntityStateMutable).entityMap[id] = entity;
89+
(state as EntityStateMutable).ids.push(id);
8490

8591
return DidMutate.Both;
8692
}
@@ -111,12 +117,12 @@ export function setEntityMutably(
111117
const id = selectId(entity);
112118

113119
if (state.entityMap[id]) {
114-
state.entityMap[id] = entity;
120+
(state as EntityStateMutable).entityMap[id] = entity;
115121
return DidMutate.Entities;
116122
}
117123

118-
state.entityMap[id] = entity;
119-
state.ids.push(id);
124+
(state as EntityStateMutable).entityMap[id] = entity;
125+
(state as EntityStateMutable).ids.push(id);
120126

121127
return DidMutate.Both;
122128
}
@@ -152,13 +158,15 @@ export function removeEntitiesMutably(
152158

153159
for (const id of ids) {
154160
if (state.entityMap[id]) {
155-
delete state.entityMap[id];
161+
delete (state as EntityStateMutable).entityMap[id];
156162
didMutate = DidMutate.Both;
157163
}
158164
}
159165

160166
if (didMutate === DidMutate.Both) {
161-
state.ids = state.ids.filter((id) => id in state.entityMap);
167+
(state as EntityStateMutable).ids = state.ids.filter(
168+
(id) => id in state.entityMap
169+
);
162170
}
163171

164172
return didMutate;
@@ -182,13 +190,16 @@ export function updateEntitiesMutably(
182190
if (entity) {
183191
const changesRecord =
184192
typeof changes === 'function' ? changes(entity) : changes;
185-
state.entityMap[id] = { ...entity, ...changesRecord };
193+
(state as EntityStateMutable).entityMap[id] = {
194+
...entity,
195+
...changesRecord,
196+
};
186197
didMutate = DidMutate.Entities;
187198

188199
const newId = selectId(state.entityMap[id]);
189200
if (newId !== id) {
190-
state.entityMap[newId] = state.entityMap[id];
191-
delete state.entityMap[id];
201+
(state as EntityStateMutable).entityMap[newId] = state.entityMap[id];
202+
delete (state as EntityStateMutable).entityMap[id];
192203

193204
newIds = newIds || {};
194205
newIds[id] = newId;
@@ -197,7 +208,7 @@ export function updateEntitiesMutably(
197208
}
198209

199210
if (newIds) {
200-
state.ids = state.ids.map((id) => newIds[id] ?? id);
211+
(state as EntityStateMutable).ids = state.ids.map((id) => newIds[id] ?? id);
201212
didMutate = DidMutate.Both;
202213
}
203214

modules/signals/entities/src/models.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,19 @@ import { Signal } from '@angular/core';
22

33
export type EntityId = string | number;
44

5-
export type EntityMap<Entity> = Record<EntityId, Entity>;
5+
export type EntityMap<Entity> = Readonly<Record<EntityId, Entity>>;
66

77
export type EntityState<Entity> = {
8-
entityMap: EntityMap<Entity>;
9-
ids: EntityId[];
8+
readonly entityMap: EntityMap<Entity>;
9+
readonly ids: readonly EntityId[];
1010
};
1111

1212
export type NamedEntityState<Entity, Collection extends string> = {
1313
[K in keyof EntityState<Entity> as `${Collection}${Capitalize<K>}`]: EntityState<Entity>[K];
1414
};
1515

1616
export type EntityProps<Entity> = {
17-
entities: Signal<Entity[]>;
17+
entities: Signal<readonly Entity[]>;
1818
};
1919

2020
export type NamedEntityProps<Entity, Collection extends string> = {

modules/signals/spec/types/signal-store.types.spec.ts

+16-16
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ describe('signalStore', () => {
3434

3535
expectSnippet(snippet).toInfer(
3636
'Store',
37-
'Type<{ foo: Signal<string>; bar: Signal<number[]>; } & StateSource<{ foo: string; bar: number[]; }>>'
37+
'Type<{ readonly foo: Signal<string>; readonly bar: Signal<number[]>; } & StateSource<{ foo: string; bar: number[]; }>>'
3838
);
3939
});
4040

@@ -64,7 +64,7 @@ describe('signalStore', () => {
6464

6565
expectSnippet(snippet).toInfer(
6666
'store',
67-
'{ user: DeepSignal<{ age: number; details: { first: string; flags: boolean[]; }; }>; } & StateSource<{ user: { age: number; details: { first: string; flags: boolean[]; }; }; }>'
67+
'{ readonly user: DeepSignal<{ age: number; details: { first: string; flags: boolean[]; }; }>; } & StateSource<{ user: { age: number; details: { first: string; flags: boolean[]; }; }; }>'
6868
);
6969

7070
expectSnippet(snippet).toInfer(
@@ -265,7 +265,7 @@ describe('signalStore', () => {
265265

266266
expectSnippet(snippet).toInfer(
267267
'store',
268-
'{ foo: Signal<number | { s: string; }>; bar: DeepSignal<{ baz: { b: boolean; } | null; }>; x: DeepSignal<{ y: { z: number | undefined; }; }>; } & StateSource<{ foo: number | { ...; }; bar: { ...; }; x: { ...; }; }>'
268+
'{ readonly foo: Signal<number | { s: string; }>; readonly bar: DeepSignal<{ baz: { b: boolean; } | null; }>; readonly x: DeepSignal<{ y: { z: number | undefined; }; }>; } & StateSource<...>'
269269
);
270270

271271
expectSnippet(snippet).toInfer('foo', 'Signal<number | { s: string; }>');
@@ -305,7 +305,7 @@ describe('signalStore', () => {
305305

306306
expectSnippet(snippet1).toInfer(
307307
'Store',
308-
'Type<{ name: DeepSignal<{ x: { y: string; }; }>; arguments: Signal<number[]>; call: Signal<boolean>; } & StateSource<{ name: { x: { y: string; }; }; arguments: number[]; call: boolean; }>>'
308+
'Type<{ readonly name: DeepSignal<{ x: { y: string; }; }>; readonly arguments: Signal<number[]>; readonly call: Signal<boolean>; } & StateSource<{ name: { x: { y: string; }; }; arguments: number[]; call: boolean; }>>'
309309
);
310310

311311
const snippet2 = `
@@ -322,7 +322,7 @@ describe('signalStore', () => {
322322

323323
expectSnippet(snippet2).toInfer(
324324
'Store',
325-
'Type<{ apply: Signal<string>; bind: DeepSignal<{ foo: string; }>; prototype: Signal<string[]>; } & StateSource<{ apply: string; bind: { foo: string; }; prototype: string[]; }>>'
325+
'Type<{ readonly apply: Signal<string>; readonly bind: DeepSignal<{ foo: string; }>; readonly prototype: Signal<string[]>; } & StateSource<{ apply: string; bind: { foo: string; }; prototype: string[]; }>>'
326326
);
327327

328328
const snippet3 = `
@@ -338,7 +338,7 @@ describe('signalStore', () => {
338338

339339
expectSnippet(snippet3).toInfer(
340340
'Store',
341-
'Type<{ length: Signal<number>; caller: Signal<undefined>; } & StateSource<{ length: number; caller: undefined; }>>'
341+
'Type<{ readonly length: Signal<number>; readonly caller: Signal<undefined>; } & StateSource<{ length: number; caller: undefined; }>>'
342342
);
343343
});
344344

@@ -393,7 +393,7 @@ describe('signalStore', () => {
393393

394394
expectSnippet(snippet).toInfer(
395395
'store',
396-
'{ bar: DeepSignal<{ baz?: number | undefined; }>; x: DeepSignal<{ y?: { z: boolean; } | undefined; }>; } & StateSource<{ bar: { baz?: number | undefined; }; x: { y?: { z: boolean; } | undefined; }; }>'
396+
'{ readonly bar: DeepSignal<{ baz?: number | undefined; }>; readonly x: DeepSignal<{ y?: { z: boolean; } | undefined; }>; } & StateSource<{ bar: { baz?: number | undefined; }; x: { ...; }; }>'
397397
);
398398

399399
expectSnippet(snippet).toInfer(
@@ -503,7 +503,7 @@ describe('signalStore', () => {
503503

504504
expectSnippet(snippet).toInfer(
505505
'store1',
506-
'{ count: Signal<number>; } & StateSource<{ count: number; }>'
506+
'{ readonly count: Signal<number>; } & StateSource<{ count: number; }>'
507507
);
508508

509509
expectSnippet(snippet).toInfer('state1', '{ count: number; }');
@@ -515,7 +515,7 @@ describe('signalStore', () => {
515515

516516
expectSnippet(snippet).toInfer(
517517
'store2',
518-
'{ count: Signal<number>; } & StateSource<{ count: number; }>'
518+
'{ readonly count: Signal<number>; } & StateSource<{ count: number; }>'
519519
);
520520

521521
expectSnippet(snippet).toInfer('state2', '{ count: number; }');
@@ -548,7 +548,7 @@ describe('signalStore', () => {
548548

549549
expectSnippet(snippet).toInfer(
550550
'store1',
551-
'{ count: Signal<number>; } & StateSource<{ count: number; }>'
551+
'{ readonly count: Signal<number>; } & StateSource<{ count: number; }>'
552552
);
553553

554554
expectSnippet(snippet).toInfer('state1', '{ count: number; }');
@@ -560,7 +560,7 @@ describe('signalStore', () => {
560560

561561
expectSnippet(snippet).toInfer(
562562
'store2',
563-
'{ count: Signal<number>; } & StateSource<{ count: number; }>'
563+
'{ readonly count: Signal<number>; } & StateSource<{ count: number; }>'
564564
);
565565

566566
expectSnippet(snippet).toInfer('state2', '{ count: number; }');
@@ -593,14 +593,14 @@ describe('signalStore', () => {
593593

594594
expectSnippet(snippet).toInfer(
595595
'store1',
596-
'{ count: Signal<number>; } & WritableStateSource<{ count: number; }>'
596+
'{ readonly count: Signal<number>; } & WritableStateSource<{ count: number; }>'
597597
);
598598

599599
expectSnippet(snippet).toInfer('state1', '{ count: number; }');
600600

601601
expectSnippet(snippet).toInfer(
602602
'store2',
603-
'{ count: Signal<number>; } & WritableStateSource<{ count: number; }>'
603+
'{ readonly count: Signal<number>; } & WritableStateSource<{ count: number; }>'
604604
);
605605

606606
expectSnippet(snippet).toInfer('state2', '{ count: number; }');
@@ -762,7 +762,7 @@ describe('signalStore', () => {
762762

763763
expectSnippet(snippet).toInfer(
764764
'store',
765-
'{ ngrx: Signal<string>; x: DeepSignal<{ y: string; }>; signals: Signal<number[]>; mgmt: (arg: boolean) => number; } & StateSource<{ ngrx: string; x: { y: string; }; }>'
765+
'{ readonly ngrx: Signal<string>; readonly x: DeepSignal<{ y: string; }>; readonly signals: Signal<number[]>; readonly mgmt: (arg: boolean) => number; } & StateSource<...>'
766766
);
767767
});
768768

@@ -790,7 +790,7 @@ describe('signalStore', () => {
790790

791791
expectSnippet(snippet).toInfer(
792792
'store',
793-
'{ foo: Signal<number>; bar: Signal<string>; baz: (x: number) => void; } & StateSource<{ foo: number; }>'
793+
'{ readonly foo: Signal<number>; readonly bar: Signal<string>; readonly baz: (x: number) => void; } & StateSource<{ foo: number; }>'
794794
);
795795
});
796796

@@ -838,7 +838,7 @@ describe('signalStore', () => {
838838

839839
expectSnippet(snippet).toInfer(
840840
'store',
841-
'{ count1: Signal<number>; doubleCount2: Signal<number>; increment1: () => void; } & StateSource<{ count1: number; }>'
841+
'{ readonly count1: Signal<number>; readonly doubleCount2: Signal<number>; readonly increment1: () => void; } & StateSource<{ count1: number; }>'
842842
);
843843
});
844844

modules/signals/src/signal-store.ts

+6-4
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ type SignalStoreConfig = { providedIn?: 'root'; protectedState?: boolean };
1313

1414
type SignalStoreMembers<FeatureResult extends SignalStoreFeatureResult> =
1515
Prettify<
16-
OmitPrivate<
17-
StateSignals<FeatureResult['state']> &
18-
FeatureResult['props'] &
19-
FeatureResult['methods']
16+
Readonly<
17+
OmitPrivate<
18+
StateSignals<FeatureResult['state']> &
19+
FeatureResult['props'] &
20+
FeatureResult['methods']
21+
>
2022
>
2123
>;
2224

modules/signals/src/state-source.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ const STATE_WATCHERS = new WeakMap<Signal<object>, Array<StateWatcher<any>>>();
1414
export const STATE_SOURCE = Symbol('STATE_SOURCE');
1515

1616
export type WritableStateSource<State extends object> = {
17-
[STATE_SOURCE]: WritableSignal<State>;
17+
readonly [STATE_SOURCE]: WritableSignal<State>;
1818
};
1919

2020
export type StateSource<State extends object> = {
21-
[STATE_SOURCE]: Signal<State>;
21+
readonly [STATE_SOURCE]: Signal<State>;
2222
};
2323

2424
export type PartialStateUpdater<State extends object> = (

0 commit comments

Comments
 (0)