From 2a141847a5ea2f24a98d5cc672df4e7abe67ec2d Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 29 Jul 2026 13:56:21 +0200 Subject: [PATCH] fix(forms): add utility to assert that value is a field tree Adds the `isFieldTree` utility that allows users to assert whether a value is a field tree. This is something that has come up on Material recently and will be useful for users as well. Fixes #69984. --- goldens/public-api/forms/signals/index.api.md | 3 ++ packages/forms/signals/public_api.ts | 1 + packages/forms/signals/src/api/assertions.ts | 20 ++++++++++ packages/forms/signals/src/api/symbols.ts | 17 ++++++++ packages/forms/signals/src/api/types.ts | 18 +++------ packages/forms/signals/src/field/proxy.ts | 17 +++++--- .../forms/signals/test/web/assertions.spec.ts | 40 +++++++++++++++++++ 7 files changed, 97 insertions(+), 19 deletions(-) create mode 100644 packages/forms/signals/src/api/assertions.ts create mode 100644 packages/forms/signals/src/api/symbols.ts create mode 100644 packages/forms/signals/test/web/assertions.spec.ts diff --git a/goldens/public-api/forms/signals/index.api.md b/goldens/public-api/forms/signals/index.api.md index baa4f157bc3..ed56a694c3d 100644 --- a/goldens/public-api/forms/signals/index.api.md +++ b/goldens/public-api/forms/signals/index.api.md @@ -299,6 +299,9 @@ export type IgnoreUnknownProperties = T extends Record // @public export const IS_ASYNC_VALIDATION_RESOURCE: unique symbol; +// @public +export function isFieldTree(value: unknown): value is FieldTree; + // @public export interface ItemFieldContext extends ChildFieldContext { readonly index: Signal; diff --git a/packages/forms/signals/public_api.ts b/packages/forms/signals/public_api.ts index e75ca6acf61..b8a05528b93 100644 --- a/packages/forms/signals/public_api.ts +++ b/packages/forms/signals/public_api.ts @@ -11,6 +11,7 @@ * @description * Entry point for all public APIs of this package. */ +export * from './src/api/assertions'; export * from './src/api/control'; export * from './src/api/di'; export * from './src/api/rules'; diff --git a/packages/forms/signals/src/api/assertions.ts b/packages/forms/signals/src/api/assertions.ts new file mode 100644 index 00000000000..f5c456c2f11 --- /dev/null +++ b/packages/forms/signals/src/api/assertions.ts @@ -0,0 +1,20 @@ +/*! + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {FIELD_TREE} from './symbols'; +import type {FieldTree} from './types'; + +/** + * Asserts whether a value is a `FieldTree`. + * @param value Value to be checked. + * + * @publicApi 22.1 + */ +export function isFieldTree(value: unknown): value is FieldTree { + return typeof value === 'function' && (value as any)[FIELD_TREE] === true; +} diff --git a/packages/forms/signals/src/api/symbols.ts b/packages/forms/signals/src/api/symbols.ts new file mode 100644 index 00000000000..cffe51192a8 --- /dev/null +++ b/packages/forms/signals/src/api/symbols.ts @@ -0,0 +1,17 @@ +/*! + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * Symbol used to retain generic type information when it would otherwise be lost. + */ +export declare const ɵɵTYPE: unique symbol; + +/** + * Symbol used to indicate that a value is a field tree. + */ +export const FIELD_TREE: unique symbol = /* @__PURE__ */ Symbol('FIELD_TREE'); diff --git a/packages/forms/signals/src/api/types.ts b/packages/forms/signals/src/api/types.ts index e7bb61a52d3..abbc7f3e390 100644 --- a/packages/forms/signals/src/api/types.ts +++ b/packages/forms/signals/src/api/types.ts @@ -10,11 +10,7 @@ import {Injector, Signal, WritableSignal} from '@angular/core'; import {AbstractControl} from '@angular/forms'; import type {FormField} from '../directive/form_field'; import type {MetadataKey, NgValidationError, ValidationError} from './rules'; - -/** - * Symbol used to retain generic type information when it would otherwise be lost. - */ -declare const ɵɵTYPE: unique symbol; +import type {ɵɵTYPE} from './symbols'; /** * Options that can be specified when submitting a form. @@ -177,8 +173,7 @@ export type TreeValidationResult< * @publicApi 22.0 */ export type ValidationResult = - | ValidationSuccess - | OneOrMany; + ValidationSuccess | OneOrMany; /** * An asynchronous validation result where all errors explicitly define their target field. @@ -195,8 +190,7 @@ export type ValidationResult = * @publicApi 22.0 */ export type AsyncValidationResult = - | ValidationResult - | 'pending'; + ValidationResult | 'pending'; /** * A field accessor function that returns the state of the field. @@ -783,8 +777,7 @@ export type SchemaPathTree = * @publicApi 22.0 */ export type MaybeSchemaPathTree = - | (TModel & undefined) - | SchemaPathTree, TPathKind>; + (TModel & undefined) | SchemaPathTree, TPathKind>; /** * A reusable schema that defines behavior and rules for a form. @@ -872,8 +865,7 @@ export type SchemaFn = ( * @publicApi 22.0 */ export type SchemaOrSchemaFn = - | Schema - | SchemaFn; + Schema | SchemaFn; /** * A function that receives the `FieldContext` for the field the logic is bound to and returns diff --git a/packages/forms/signals/src/field/proxy.ts b/packages/forms/signals/src/field/proxy.ts index f2b91b3dc13..d1ed786c489 100644 --- a/packages/forms/signals/src/field/proxy.ts +++ b/packages/forms/signals/src/field/proxy.ts @@ -9,16 +9,21 @@ import {untracked} from '@angular/core'; import {isArray, isObject} from '../util/type_guards'; import type {FieldNode} from './node'; +import {FIELD_TREE} from '../api/symbols'; /** * Proxy handler which implements `FieldTree` on top of `FieldNode`. */ export const FIELD_PROXY_HANDLER: ProxyHandler<() => FieldNode> = { - get(getTgt: () => FieldNode, p: string | symbol, receiver: {[key: string]: unknown}) { + get(getTgt: () => FieldNode, property: string | symbol, receiver: Record) { + if (property === FIELD_TREE) { + return true; + } + const tgt = getTgt(); // First, check whether the requested property is a defined child node of this node. - const child = tgt.structure.getChild(p); + const child = tgt.structure.getChild(property); if (child !== undefined) { // If so, return the child node's `FieldTree` proxy, allowing the developer to continue // navigating the form structure. @@ -35,12 +40,12 @@ export const FIELD_PROXY_HANDLER: ProxyHandler<() => FieldNode> = { if (isArray(value)) { // Allow access to the length for field arrays, it should be the same as the length of the data. - if (p === 'length') { + if (property === 'length') { return (tgt.value() as Array).length; } // Allow access to the iterator. This allows the user to spread the field array into a // standard array in order to call methods like `filter`, `map`, etc. - if (p === Symbol.iterator) { + if (property === Symbol.iterator) { return () => { // When creating an iterator, we need to account for reactivity. The iterator itself will // read things each time `.next()` is called, but that may happen outside of the context @@ -59,7 +64,7 @@ export const FIELD_PROXY_HANDLER: ProxyHandler<() => FieldNode> = { if (isObject(value)) { // For object fields, allow iteration over their entries for convenience of use with `@for`. - if (p === Symbol.iterator) { + if (property === Symbol.iterator) { return function* () { for (const key in receiver) { yield [key, receiver[key]]; @@ -72,7 +77,7 @@ export const FIELD_PROXY_HANDLER: ProxyHandler<() => FieldNode> = { return undefined; }, - getOwnPropertyDescriptor(getTgt, prop) { + getOwnPropertyDescriptor(getTgt: () => FieldNode, prop: string | symbol) { const value = untracked(getTgt().value) as Object; const desc = Reflect.getOwnPropertyDescriptor(value, prop); // In order for `Object.keys` to function properly, keys must be reported as configurable. diff --git a/packages/forms/signals/test/web/assertions.spec.ts b/packages/forms/signals/test/web/assertions.spec.ts new file mode 100644 index 00000000000..e9f9b664f64 --- /dev/null +++ b/packages/forms/signals/test/web/assertions.spec.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Component, signal} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {form, isFieldTree} from '../../public_api'; + +describe('assertions', () => { + it('should detect field trees', () => { + @Component({template: ''}) + class App { + readonly form = form( + signal({ + firstName: 'Frodo', + lastName: 'Baggins', + }), + ); + } + + const fixture = TestBed.createComponent(App); + const tree = fixture.componentInstance.form; + expect(isFieldTree(tree)).toBe(true); + expect(isFieldTree(tree().fieldTree)).toBe(true); + }); + + it('should distinguish non-field-tree values from field trees', () => { + expect(isFieldTree(true)).toBe(false); + expect(isFieldTree(1)).toBe(false); + expect(isFieldTree({})).toBe(false); + expect(isFieldTree(() => 123)).toBe(false); + expect(isFieldTree(null)).toBe(false); + expect(isFieldTree(undefined)).toBe(false); + expect(isFieldTree(signal(1))).toBe(false); + }); +});