From 44c4da6c03a7b9a54809a775f850b6c1bbe46180 Mon Sep 17 00:00:00 2001 From: Michael Small Date: Wed, 11 Feb 2026 17:26:41 -0600 Subject: [PATCH 1/4] refactor: add WIP types for mutation errors --- .../counter-rx-mutation.ts | 16 +++++++-- .../src/lib/mutation/http-mutation.ts | 20 +++++++---- .../ngrx-toolkit/src/lib/mutation/mutation.ts | 12 +++---- .../src/lib/mutation/rx-mutation.ts | 34 ++++++++++--------- libs/ngrx-toolkit/src/lib/with-mutations.ts | 5 +-- 5 files changed, 54 insertions(+), 33 deletions(-) diff --git a/apps/demo/src/app/counter-rx-mutation/counter-rx-mutation.ts b/apps/demo/src/app/counter-rx-mutation/counter-rx-mutation.ts index ca3d4729..4a5a77bb 100644 --- a/apps/demo/src/app/counter-rx-mutation/counter-rx-mutation.ts +++ b/apps/demo/src/app/counter-rx-mutation/counter-rx-mutation.ts @@ -5,7 +5,7 @@ import { } from '@angular-architects/ngrx-toolkit'; import { CommonModule } from '@angular/common'; import { Component, computed, signal } from '@angular/core'; -import { delay, Observable, of, throwError } from 'rxjs'; +import { catchError, delay, Observable, of, throwError } from 'rxjs'; export type Params = { value: number; @@ -51,6 +51,12 @@ export class CounterRxMutation { onSuccess: (response) => { console.log('Counter sent to server:', response); }, + // TODO - In the current state, parsing for the error does not narrow unless either + // - 3rd error type generic specified + // - No generic types specified + // This is inline with the existing configuration of this `httpMutation` example before this proposed change + // If you uncomment the following, you must still specify the error type or drop the explicit types + // parseError: (error) => error as string, onError: (error) => { console.error('Failed to send counter:', error); }, @@ -108,5 +114,11 @@ function calcSum(a: number, b: number): Observable { result, })); } - return of(result).pipe(delay(500)); + return of(result).pipe( + delay(500), + catchError((error) => { + console.error('Error in calcSum:', error); + return of(1); + }), + ); } diff --git a/libs/ngrx-toolkit/src/lib/mutation/http-mutation.ts b/libs/ngrx-toolkit/src/lib/mutation/http-mutation.ts index 37ff9989..c6e6a9b7 100644 --- a/libs/ngrx-toolkit/src/lib/mutation/http-mutation.ts +++ b/libs/ngrx-toolkit/src/lib/mutation/http-mutation.ts @@ -41,15 +41,20 @@ export type HttpMutationRequest = { | boolean; }; -export type HttpMutationOptions = Omit< - RxMutationOptions>, +export type HttpMutationOptions = Omit< + RxMutationOptions, NoInfer>, 'operation' > & { request: (param: Parameter) => HttpMutationRequest; parse?: (response: unknown) => Result; + parseError?: (error: unknown) => Err; }; -export type HttpMutation = Mutation & { +export type HttpMutation = Mutation< + Parameter, + Result, + Err +> & { uploadProgress: Signal; downloadProgress: Signal; headers: Signal; @@ -108,11 +113,11 @@ export type HttpMutation = Mutation & { * @param options The options for the HTTP mutation. * @returns The HTTP mutation. */ -export function httpMutation( +export function httpMutation( optionsOrRequest: - | HttpMutationOptions + | HttpMutationOptions | ((param: Parameter) => HttpMutationRequest), -): HttpMutation { +): HttpMutation { const httpClient = inject(HttpClient); const options = @@ -121,6 +126,7 @@ export function httpMutation( : optionsOrRequest; const parse = options.parse ?? ((raw: unknown) => raw as Result); + const parseError = options.parseError ?? ((raw: unknown) => raw as Err); const uploadProgress = signal(undefined); const downloadProgress = signal(undefined); @@ -161,7 +167,7 @@ export function httpMutation( ); }); }, - }) as HttpMutation; + }) as HttpMutation; mutation.uploadProgress = uploadProgress; mutation.downloadProgress = downloadProgress; diff --git a/libs/ngrx-toolkit/src/lib/mutation/mutation.ts b/libs/ngrx-toolkit/src/lib/mutation/mutation.ts index cb73a9a1..681d7884 100644 --- a/libs/ngrx-toolkit/src/lib/mutation/mutation.ts +++ b/libs/ngrx-toolkit/src/lib/mutation/mutation.ts @@ -1,13 +1,13 @@ import { Signal } from '@angular/core'; -export type MutationResult = +export type MutationResult = | { status: 'success'; value: Result; } | { status: 'error'; - error: unknown; + error: Err; } | { status: 'aborted'; @@ -15,12 +15,12 @@ export type MutationResult = export type MutationStatus = 'idle' | 'pending' | 'error' | 'success'; -export type Mutation = { - (params: Parameter): Promise>; +export type Mutation = { + (params: Parameter): Promise>; status: Signal; value: Signal; isPending: Signal; isSuccess: Signal; - error: Signal; - hasValue(): this is Mutation, Result>; + error: Signal; + hasValue(): this is Mutation, Result, Err>; }; diff --git a/libs/ngrx-toolkit/src/lib/mutation/rx-mutation.ts b/libs/ngrx-toolkit/src/lib/mutation/rx-mutation.ts index cccf1772..dbb999ff 100644 --- a/libs/ngrx-toolkit/src/lib/mutation/rx-mutation.ts +++ b/libs/ngrx-toolkit/src/lib/mutation/rx-mutation.ts @@ -13,12 +13,14 @@ import { import { concatOp, FlatteningOperator } from '../flattening-operator'; import { Mutation, MutationResult, MutationStatus } from './mutation'; -export type Operation = (param: Parameter) => Result; +export type Operation = ( + param: Parameter, +) => Result; -export interface RxMutationOptions { - operation: Operation>; +export interface RxMutationOptions { + operation: Operation, Observable>; onSuccess?: (result: Result, param: Parameter) => void; - onError?: (error: unknown, param: Parameter) => void; + onError?: (error: Err, param: Parameter) => void; operator?: FlatteningOperator; injector?: Injector; } @@ -86,14 +88,14 @@ export interface RxMutationOptions { * @param options * @returns the actual mutation function along tracking data as properties/methods */ -export function rxMutation( +export function rxMutation( optionsOrOperation: - | RxMutationOptions - | Operation>, -): Mutation { + | RxMutationOptions + | Operation, Observable>, +): Mutation { const inputSubject = new Subject<{ param: Parameter; - resolve: (result: MutationResult) => void; + resolve: (result: MutationResult) => void; }>(); const options = @@ -106,15 +108,15 @@ export function rxMutation( const destroyRef = options.injector?.get(DestroyRef) ?? inject(DestroyRef); const callCount = signal(0); - const errorSignal = signal(undefined); + const errorSignal = signal(undefined); const idle = signal(true); const isPending = computed(() => callCount() > 0); const value = signal(undefined); const isSuccess = computed(() => !idle() && !isPending() && !errorSignal()); const hasValue = function ( - this: Mutation, - ): this is Mutation, Result> { + this: Mutation, + ): this is Mutation, Result, Err> { return typeof value() !== 'undefined'; }; @@ -147,7 +149,7 @@ export function rxMutation( errorSignal.set(undefined); value.set(result); }), - catchError((error: unknown) => { + catchError((error: Err) => { options.onError?.(error, input.param); errorSignal.set(error); value.set(undefined); @@ -165,7 +167,7 @@ export function rxMutation( } else if (innerStatus === 'error') { input.resolve({ status: 'error', - error: errorSignal(), + error: errorSignal() as Err, }); } else { input.resolve({ @@ -183,7 +185,7 @@ export function rxMutation( .subscribe(); const mutationFn = (param: Parameter) => { - return new Promise>((resolve) => { + return new Promise>((resolve) => { if (callCount() > 0 && flatteningOp.exhaustSemantics) { resolve({ status: 'aborted', @@ -197,7 +199,7 @@ export function rxMutation( }); }; - const mutation = mutationFn as Mutation; + const mutation = mutationFn as Mutation; mutation.status = status; mutation.isPending = isPending; mutation.error = errorSignal; diff --git a/libs/ngrx-toolkit/src/lib/with-mutations.ts b/libs/ngrx-toolkit/src/lib/with-mutations.ts index 8c1a03e6..3641b3a1 100644 --- a/libs/ngrx-toolkit/src/lib/with-mutations.ts +++ b/libs/ngrx-toolkit/src/lib/with-mutations.ts @@ -14,7 +14,7 @@ import { Mutation, MutationResult, MutationStatus } from './mutation/mutation'; // NamedMutationMethods below will infer the actual parameter and return types // eslint-disable-next-line @typescript-eslint/no-explicit-any -type MutationsDictionary = Record>; +type MutationsDictionary = Record>; // withMethods uses Record internally // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type @@ -31,7 +31,8 @@ type NamedMutationProps = { type NamedMutationMethods = { [Prop in keyof T as `${Prop & string}`]: T[Prop] extends Mutation< infer P, - infer R + infer R, + infer E > ? (params: P) => Promise> : never; From 4238af1a2970851e750b5f4a23454fdd170256b1 Mon Sep 17 00:00:00 2001 From: Michael Small Date: Sat, 30 May 2026 14:43:34 -0500 Subject: [PATCH 2/4] chore: add error type to named mutation methods --- libs/ngrx-toolkit/src/lib/with-mutations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/ngrx-toolkit/src/lib/with-mutations.ts b/libs/ngrx-toolkit/src/lib/with-mutations.ts index 3641b3a1..63a4c32e 100644 --- a/libs/ngrx-toolkit/src/lib/with-mutations.ts +++ b/libs/ngrx-toolkit/src/lib/with-mutations.ts @@ -34,7 +34,7 @@ type NamedMutationMethods = { infer R, infer E > - ? (params: P) => Promise> + ? (params: P) => Promise> : never; }; From 48f4cd143ee384949ed597b76445a60889403333 Mon Sep 17 00:00:00 2001 From: Michael Small Date: Sat, 30 May 2026 14:46:01 -0500 Subject: [PATCH 3/4] chore: default mutation error to `unknown` --- libs/ngrx-toolkit/src/lib/mutation/mutation.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/ngrx-toolkit/src/lib/mutation/mutation.ts b/libs/ngrx-toolkit/src/lib/mutation/mutation.ts index 681d7884..a5c8efde 100644 --- a/libs/ngrx-toolkit/src/lib/mutation/mutation.ts +++ b/libs/ngrx-toolkit/src/lib/mutation/mutation.ts @@ -1,6 +1,6 @@ import { Signal } from '@angular/core'; -export type MutationResult = +export type MutationResult = | { status: 'success'; value: Result; @@ -15,7 +15,7 @@ export type MutationResult = export type MutationStatus = 'idle' | 'pending' | 'error' | 'success'; -export type Mutation = { +export type Mutation = { (params: Parameter): Promise>; status: Signal; value: Signal; From 1cc34aff1bcd0e512a206efe05dcda5d608bf6e5 Mon Sep 17 00:00:00 2001 From: Michael Small Date: Tue, 2 Jun 2026 21:13:50 -0500 Subject: [PATCH 4/4] chore: fix lint issues in mutation tests --- libs/ngrx-toolkit/src/lib/with-mutations.spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/libs/ngrx-toolkit/src/lib/with-mutations.spec.ts b/libs/ngrx-toolkit/src/lib/with-mutations.spec.ts index e40812df..07cfe14b 100644 --- a/libs/ngrx-toolkit/src/lib/with-mutations.spec.ts +++ b/libs/ngrx-toolkit/src/lib/with-mutations.spec.ts @@ -573,16 +573,16 @@ describe('withMutations with rxMutation', () => { // CANNOT... // @ts-expect-error should not expose properties, only method call - const status = store.increment.status; + const _status = store.increment.status; // @ts-expect-error should not expose properties, only method call - const value = store.increment.value; + const _value = store.increment.value; // @ts-expect-error should not expose properties, only method call - const isPending = store.increment.isPending; + const _isPending = store.increment.isPending; // @ts-expect-error should not expose properties, only method call - const isSuccess = store.increment.isSuccess; + const _isSuccess = store.increment.isSuccess; // @ts-expect-error should not expose properties, only method call - const error = store.increment.error; + const _error = store.increment.error; // @ts-expect-error should not expose properties, only method call - const hasValue = store.increment.hasValue; + const _hasValue = store.increment.hasValue; }); });