Skip to content

RFC: Angular SignalArray and SignalDict #1

@ducin

Description

@ducin

👉 see full typescript playground for arrays

👉 see full typescript playground for objects

Motivation

  1. 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

  1. 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(): T

and 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() // Person

SignalDict

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>

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions