Skip to content

Commit 83032e3

Browse files
alxhubmattrbeck
authored andcommitted
fix(forms): support generic unions in signal form schemas
This commit resolves an issue where using an uninstantiated generic type parameter in a signal form model caused TypeScript compilation failures due to distributive conditional types (#66596). The previous attempt to fix this issue by tuple-wrapping everything caused another bug (#65535) that prevented property access on generic unions. This commit balances the need to resolve nested generic property access while handling infinitely recursive generic structures without depth errors. What changed and why: - Base State Wrappers: Tuple wrappers (`[TModel] extends [AbstractControl]`) are applied to `FieldTreeBase` to safely defer generic evaluation. This prevents primitive unions (like `boolean`) from incorrectly evaluating to `never`. - Naked Map Over Children: Object subfield checks (`TModel extends Record`) are re-evaluated as purely naked conditionals. Eager distribution over generics allows users to directly access shared properties of unresolved union types. - Array Interface Deflection: `ReadonlyArrayLike<T>` generic abstraction is redefined as an explicit `interface` instead of a mapped `Pick` type alias. This optimally intercepts TypeScript from eagerly evaluating infinitely recursive array structures (e.g. `RecursiveType = (number | RecursiveType)[]`). - Overloaded Context Methods: `FieldNodeContext.stateOf` and `fieldTreeOf` are defined as explicitly overloaded class methods and lexically bound (`this`) in the constructor. These changes are required to safely align the runtime bindings with the tautological conditionals implemented in the `RootFieldContext` interface structure. Fixes #65535
1 parent 1eaf920 commit 83032e3

File tree

4 files changed

+76
-20
lines changed

4 files changed

+76
-20
lines changed

goldens/public-api/forms/signals/index.api.md

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ export interface FieldState<TValue, TKey extends string | number = string | numb
140140
export type FieldStateByMode<TValue, TKey extends string | number, TMode extends 'writable' | 'readonly'> = TMode extends 'writable' ? FieldState<TValue, TKey> : ReadonlyFieldState<TValue, TKey>;
141141

142142
// @public
143-
export type FieldTree<TModel, TKey extends string | number = string | number, TMode extends 'writable' | 'readonly' = 'writable'> = (() => [TModel] extends [AbstractControl] ? CompatFieldState<TModel, TKey, TMode> : FieldStateByMode<TModel, TKey, TMode>) & ([TModel] extends [AbstractControl] ? object : [TModel] extends [ReadonlyArray<infer U>] ? ReadonlyArrayLike<MaybeFieldTree<U, number, TMode>> : TModel extends Record<string, any> ? Subfields<TModel, TMode> : object);
143+
export type FieldTree<TModel, TKey extends string | number = string | number, TMode extends 'writable' | 'readonly' = 'writable'> = (() => [TModel] extends [AbstractControl] ? CompatFieldState<TModel, TKey, TMode> : FieldStateByMode<TModel, TKey, TMode>) & (TModel extends AbstractControl ? object : TModel extends ReadonlyArray<infer U> ? ReadonlyArrayLike<MaybeFieldTree<U, number, TMode>> : TModel extends Record<string, any> ? Subfields<TModel, TMode> : object);
144144

145145
// @public
146146
export type FieldValidator<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<TValue, ValidationResult<ValidationError.WithoutFieldTree>, TPathKind>;
@@ -476,7 +476,14 @@ export function provideSignalFormsConfig(config: SignalFormsConfig): Provider[];
476476
export function readonly<TValue, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, logic?: NoInfer<LogicFn<TValue, boolean, TPathKind>>): void;
477477

478478
// @public
479-
export type ReadonlyArrayLike<T> = Pick<ReadonlyArray<T>, number | 'length' | typeof Symbol.iterator>;
479+
export interface ReadonlyArrayLike<T> {
480+
// (undocumented)
481+
[Symbol.iterator](): IterableIterator<T>;
482+
// (undocumented)
483+
readonly [n: number]: T;
484+
// (undocumented)
485+
readonly length: number;
486+
}
480487

481488
// @public
482489
export type ReadonlyCompatFieldState<TControl extends AbstractControl, TKey extends string | number = string | number> = CompatFieldState<TControl, TKey, 'readonly'>;
@@ -543,12 +550,12 @@ export class RequiredValidationError extends BaseNgValidationError {
543550
// @public
544551
export interface RootFieldContext<TValue> {
545552
readonly fieldTree: ReadonlyFieldTree<TValue>;
546-
fieldTreeOf<PModel>(p: SchemaPathTree<PModel>): ReadonlyFieldTree<PModel>;
553+
fieldTreeOf<PModel>(p: SchemaPathTree<PModel>): [PModel] extends [any] ? ReadonlyFieldTree<PModel> : never;
547554
readonly pathKeys: Signal<readonly string[]>;
548555
readonly state: ReadonlyFieldState<TValue>;
549-
stateOf<PControl extends AbstractControl>(p: CompatSchemaPath<PControl>): ReadonlyCompatFieldState<PControl>;
556+
stateOf<PControl extends AbstractControl>(p: CompatSchemaPath<PControl>): [PControl] extends [any] ? ReadonlyCompatFieldState<PControl> : never;
550557
// (undocumented)
551-
stateOf<PValue>(p: SchemaPath<PValue, SchemaPathRules>): ReadonlyFieldState<PValue>;
558+
stateOf<PValue>(p: SchemaPath<PValue, SchemaPathRules>): [PValue] extends [any] ? ReadonlyFieldState<PValue> : never;
552559
readonly value: Signal<TValue>;
553560
valueOf<PValue>(p: SchemaPath<PValue, SchemaPathRules>): PValue;
554561
}
@@ -586,7 +593,9 @@ export namespace SchemaPathRules {
586593
}
587594

588595
// @public
589-
export type SchemaPathTree<TModel, TPathKind extends PathKind = PathKind.Root> = ([TModel] extends [AbstractControl] ? CompatSchemaPath<TModel, TPathKind> : SchemaPath<TModel, SchemaPathRules.Supported, TPathKind>) & (TModel extends AbstractControl ? unknown : TModel extends ReadonlyArray<any> ? unknown : TModel extends Record<string, any> ? {
596+
export type SchemaPathTree<TModel, TPathKind extends PathKind = PathKind.Root> = ([TModel] extends [AbstractControl] ? CompatSchemaPath<TModel, TPathKind> : SchemaPath<TModel, SchemaPathRules.Supported, TPathKind>) & ([TModel] extends [AbstractControl] ? unknown : [
597+
TModel
598+
] extends [ReadonlyArray<any>] ? unknown : TModel extends Record<string, any> ? {
590599
[K in keyof TModel]: MaybeSchemaPathTree<TModel[K], PathKind.Child>;
591600
} : unknown);
592601

packages/forms/signals/src/api/types.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -232,9 +232,9 @@ export type FieldTree<
232232
? CompatFieldState<TModel, TKey, TMode>
233233
: FieldStateByMode<TModel, TKey, TMode>) &
234234
// Children:
235-
([TModel] extends [AbstractControl]
235+
(TModel extends AbstractControl
236236
? object
237-
: [TModel] extends [ReadonlyArray<infer U>]
237+
: TModel extends ReadonlyArray<infer U>
238238
? ReadonlyArrayLike<MaybeFieldTree<U, number, TMode>>
239239
: TModel extends Record<string, any>
240240
? Subfields<TModel, TMode>
@@ -277,10 +277,11 @@ export type Subfields<TModel, TMode extends 'writable' | 'readonly' = 'writable'
277277
*
278278
* @experimental 21.0
279279
*/
280-
export type ReadonlyArrayLike<T> = Pick<
281-
ReadonlyArray<T>,
282-
number | 'length' | typeof Symbol.iterator
283-
>;
280+
export interface ReadonlyArrayLike<T> {
281+
readonly [n: number]: T;
282+
readonly length: number;
283+
[Symbol.iterator](): IterableIterator<T>;
284+
}
284285

285286
/**
286287
* Helper type for defining `FieldTree`. Given a type `TValue` that may include `undefined`,
@@ -697,10 +698,10 @@ export type SchemaPathTree<TModel, TPathKind extends PathKind = PathKind.Root> =
697698
? CompatSchemaPath<TModel, TPathKind>
698699
: SchemaPath<TModel, SchemaPathRules.Supported, TPathKind>) &
699700
// Subpaths
700-
(TModel extends AbstractControl
701+
([TModel] extends [AbstractControl]
701702
? unknown
702703
: // Array paths have no subpaths
703-
TModel extends ReadonlyArray<any>
704+
[TModel] extends [ReadonlyArray<any>]
704705
? unknown
705706
: // Object subfields
706707
TModel extends Record<string, any>
@@ -903,13 +904,24 @@ export interface RootFieldContext<TValue> {
903904
/** Gets the value of the field represented by the given path. */
904905
valueOf<PValue>(p: SchemaPath<PValue, SchemaPathRules>): PValue;
905906

907+
// Note: These methods use a tautological conditional type (`[P] extends [any] ? ... : never`).
908+
// This is required because the `FieldNodeContext` implementation returns a deferred conditional
909+
// type from `FieldTreeBase` (e.g. `[P] extends [AbstractControl] ? ...`).
910+
// TypeScript is strictly invariant when matching generic method signatures and will throw an
911+
// assignability error if the interface signature is fully resolved while the implementation
912+
// signature is a deferred conditional. This tautological wrapper mimics the deferred
913+
// structure so that TypeScript accepts the assignment gracefully.
906914
/** Gets the state of the field represented by the given path. */
907915
stateOf<PControl extends AbstractControl>(
908916
p: CompatSchemaPath<PControl>,
909-
): ReadonlyCompatFieldState<PControl>;
910-
stateOf<PValue>(p: SchemaPath<PValue, SchemaPathRules>): ReadonlyFieldState<PValue>;
917+
): [PControl] extends [any] ? ReadonlyCompatFieldState<PControl> : never;
918+
stateOf<PValue>(
919+
p: SchemaPath<PValue, SchemaPathRules>,
920+
): [PValue] extends [any] ? ReadonlyFieldState<PValue> : never;
911921
/** Gets the field represented by the given path. */
912-
fieldTreeOf<PModel>(p: SchemaPathTree<PModel>): ReadonlyFieldTree<PModel>;
922+
fieldTreeOf<PModel>(
923+
p: SchemaPathTree<PModel>,
924+
): [PModel] extends [any] ? ReadonlyFieldTree<PModel> : never;
913925
/** The list of keys that lead from the root field to the current field. */
914926
readonly pathKeys: Signal<readonly string[]>;
915927
}

packages/forms/signals/src/field/context.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ import {
1616
import {RuntimeErrorCode} from '../errors';
1717
import {AbstractControl} from '@angular/forms';
1818
import {
19+
CompatSchemaPath,
20+
CompatFieldState,
1921
FieldContext,
22+
ReadonlyCompatFieldState,
2023
ReadonlyFieldState,
2124
ReadonlyFieldTree,
2225
SchemaPath,
@@ -48,7 +51,13 @@ export class FieldNodeContext implements FieldContext<unknown> {
4851
constructor(
4952
/** The field node this context corresponds to. */
5053
private readonly node: FieldNode,
51-
) {}
54+
) {
55+
// These methods are explicitly bound to the context instance so that they
56+
// safely retain their `this` reference if destructured by consumers
57+
// (e.g., during validation when `stateOf` or `fieldTreeOf` are extracted).
58+
this.fieldTreeOf = this.fieldTreeOf.bind(this);
59+
this.stateOf = this.stateOf.bind(this);
60+
}
5261

5362
/**
5463
* Resolves a target path relative to this context.
@@ -137,8 +146,25 @@ export class FieldNodeContext implements FieldContext<unknown> {
137146
return Number(key);
138147
});
139148

140-
readonly fieldTreeOf = <TModel>(p: SchemaPathTree<TModel>) => this.resolve<TModel>(p);
141-
readonly stateOf = <TModel>(p: SchemaPath<TModel, SchemaPathRules>) => this.resolve<TModel>(p)();
149+
// Note: `fieldTreeOf` and `stateOf` are purposefully defined as overloaded class
150+
// methods rather than arrow-function properties. This allows their signatures
151+
// to successfully satisfy the complex, deferred conditional generic typings
152+
// required by the `RootFieldContext` interface.
153+
fieldTreeOf<PModel>(
154+
p: SchemaPathTree<PModel>,
155+
): [PModel] extends [any] ? ReadonlyFieldTree<PModel> : never {
156+
return this.resolve<PModel>(p) as any;
157+
}
158+
159+
stateOf<PControl extends AbstractControl>(
160+
p: CompatSchemaPath<PControl>,
161+
): [PControl] extends [any] ? ReadonlyCompatFieldState<PControl> : never;
162+
stateOf<PValue>(
163+
p: SchemaPath<PValue, SchemaPathRules>,
164+
): [PValue] extends [any] ? ReadonlyFieldState<PValue> : never;
165+
stateOf<TModel>(p: SchemaPath<TModel, SchemaPathRules> | CompatSchemaPath<any>): any {
166+
return this.resolve<TModel>(p as any)();
167+
}
142168
readonly valueOf = <TValue>(p: SchemaPath<TValue, SchemaPathRules>) => {
143169
const result = this.resolve(p)().value();
144170

packages/forms/signals/test/node/types.spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,15 @@ function typeVerificationOnlyDoNotRunMe() {
9797
form(signal<RecursiveType>([5]));
9898
});
9999

100+
it('should allow property access on generic type unions', () => {
101+
// Validates that uninstantiated generic unions can still access shared
102+
// fields via naked conditional distribution in FieldTree
103+
function testGeneric<T extends {a: 1} | {a: 1; b: 2}>(f: FieldTree<T>) {
104+
const x: FieldTree<1> = f.a;
105+
return x;
106+
}
107+
});
108+
100109
it('should allow ReadonlyArray in model and be iterable', () => {
101110
interface Order {
102111
readonly products: readonly string[];

0 commit comments

Comments
 (0)