-
Notifications
You must be signed in to change notification settings - Fork 1
Description
👉 see full typescript playground for arrays
👉 see full typescript playground for objects
Motivation
- Missing reactive APIs for arrays, objects
SignalArrays are meant to make arrays reactive. Native arrays, being mutable and non-reactive, require being wrapped with computed calls e.g. to make them reactive in case of displaying in templates or re-running side effects.
Wrapping an array calculation with computed is a simple task. However, in big scale it can become tedious and, as Angular will introduce signal/zoneless components, reactive arrays make a great fit. Knowing and, first and foremost, having to adjust mutable JS APIs into reactive Angular APIs is an implementation detail that shall not be developer's responsibility. Especially in Signal Components
- Slight performance improvements
For big data sets, having one signal can affect performance (changes on ingle items make the entire signal notify about a change). Creating a collection (array/dict) of manually maintained smaller signals introduces boilerplate. This is where collection signals kick in.
Note that NGRX signal store does exactly this with its DeepSignals.
Scope
The scope of this RFC is the API design of native JS Array and Object equivalents.
Approach
key points:
- SignalArray and SignalDict are still native angular signals. All computed/effects/templates/etc will work seamlessly - call them directly the same as you would with ordinary signals (no additional un-wrapping required)
- both are DeepSignals (composition, perf)
SignalArray
Following interfaces illustrate a rather precise API proposal:
import { Signal, WritableSignal } from '@angular/core';
interface SignalArray<T> extends Signal<T[]> {
[idx: number]: WritableSignal<T>;
at(idx: number): WritableSignal<T>;
[Symbol.iterator](): IterableIterator<T>;
length: Signal<number>;
slice(start?: number, end?: number): SignalArray<T>;
reverse(): SignalArray<T>;
sort(compareFn: (a: T, b: T) => number): SignalArray<T>;
with(index: number, value: T): SignalArray<T>;
map<U>(mapperFn: (t: T) => U): SignalArray<U>;
flatMap<U>(mapperFn: (t: T) => U[]): SignalArray<U>;
filter(predicateFn: (t: T) => unknown): SignalArray<T>;
find(predicateFn: (t: T) => unknown): Signal<T>;
every(predicateFn: (t: T) => unknown): Signal<boolean>;
some(predicateFn: (t: T) => unknown): Signal<boolean>;
// skipping overloads for simplicity
reduce<U>(reducerFn: (acc: U, t: T) => U, initial: U): Signal<U>;
}
interface WritableSignalArray<T> extends SignalArray<T> {
// returns THE SAME signal, enables chaining/fluent API
push(...items: T[]): WritableSignalArray<T>;
// returns THE SAME signal, enables chaining/fluent API
unshift(...items: T[]): WritableSignalArray<T>;
pop(): T
shift(): Tand following usage examples describe the characteristics:
interface Person {
id: number
name: string
languages: string[]
}
declare const people: WritableSignalArray<Person>1. SignalArray is a signal itself
const itemsToIterateOver = people() // Person[]
const eagerIteration = [...people] // Person[]2. each item is a signal as well (slight performance improvement)
const firstPerson = people[0]
firstPerson.update(p => ({...p, languages: [...p.languages, 'JAVA 😈']}))
people.at(-1).update(p => ({...p, languages: [...p.languages, 'JAVA 😈']}))3. all methods return computed signals
const len = people.length // Signal<number>
const lenInsideTemplate = len() // number
const allPeopleKnowAtLeastOneLanguage = people.every(p => p.languages.length > 0) // Signal<boolean>4. all array-processing methods try to resemble Array API AMAP for convenience
const sortedLabels = people
.sort((p1, p2) => p1.languages.length - p2.languages.length)
.map(p => `${p.name}: ${p.languages.join(', ')}`)
// SignalArray<string>, can be further processed with SignalArray API
const sortedLabelsCount = sortedLabels.length // Signal<number>5. mutations - NOT signals
modifying the SignalArray changes its state, never return a new signal (equivalent to signa.update)
declare const person: Person
const theSameSignal = people.push(person, person, person) // WritableSignalArray<Person>
const chainedButTheSameSignalAllTheTime = people
.push(person)
.push(person)
.push(person)
// // WritableSignalArray<Person>
const removedItem = people.pop() // PersonSignalDict
Following interfaces illustrate a rather precise API proposal. Note that - similar with static Object methods which use Array methods - some of SignalDict methods use SignalArray methods too:
interface SignalDict<K extends PropertyKey, T> extends Signal<Record<K, T>> {
keys(): SignalArray<K> // `Object.keys(obj)` equivalent
values(): SignalArray<T> // `Object.values(obj)` equivalent
entries(): SignalArray<[K, T]> // `Object.values(obj)` equivalent
at(key: K): WritableSignal<T> // `obj[key]` equivalent
has(key: K): Signal<boolean> // `key in obj` equivalent
add<Q extends PropertyKey>(key: Q, t: T): SignalDict<K | Q, T> // `delete obj[key]` equivalent
delete(key: K): T // `delete obj[key]` equivalent
}and usage
```ts
interface Person {
id: number
name: string
languages: string[]
}
declare const person: Person
// non-discriminant property set
declare const unknownKeysPeople: SignalDict<string, Person>
// discriminant property set
declare const strictKeysPeople: SignalDict<'Alice' | 'Bob' | 'Charlie', Person>1. all object-processing methods try to resemble Object API
1.1 items
const unknownKeysHaveDan = unknownKeysPeople.has('Dan') // Signal<boolean>
const strictKeysHaveDan = strictKeysPeople.has('Dan') // ❌ 👍 expected to fail
const strictKeysHaveBob = strictKeysPeople.has('Bob') // Signal<boolean>1.2 collection - chainable with SignalArray
const peopleLabels = unknownKeysPeople
.values()
.filter(p => p.languages.length > 0)
.map(p => `${p.name}: ${p.languages.join(', ')}`)
// SignalArray<string>
const reducedBackToObject = strictKeysPeople
.entries()
.map(([key, value]) => [`Dear ${key}`, value] as const)
.reduce((acc: { [key in (typeof key)] : typeof value}, [key, value]) => {
type K = typeof key
acc[key] = value
return acc
}, {} as any) // this type assertions is unavoidable within a chained call
// Signal<{ "Dear Alice": Person; "Dear Bob": Person; "Dear Charlie": Person; }>2. mutations - NOT signals
modifying the SignalArray changes its state, never return a new signal
2.1 modifications
unknownKeysPeople.at('Sebix').update(p => ({...p, languages: [...p.languages, 'JAVA 😈']}))2.2 insertions
const unknownKeysWithDan = unknownKeysPeople.add('Dan', person) // SignalDict<string, Person>
const strictKeysWithDan = strictKeysPeople.add('Dan', person) // SignalDict<"Alice" | "Bob" | "Charlie" | "Dan", Person>2.3 removals
const removed = unknownKeysPeople.delete('Sebix')
unknownKeysPeople; // Person
// still unknownKeysPeople: SignalDict<string, Person>
// same with strictKeysPeople due to `delete(key: K): T`
// but if `delete` implemented chaining/fluent API and returned the signal, it could have the chosen key removed:
// delete<Q extends K>(key: Q): SignalDict<Exclude<K, Q>, T>