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.
This commit is contained in:
Kristiyan Kostadinov
2026-07-29 13:56:21 +02:00
committed by Alex Rickabaugh
parent d44b3224d9
commit 2a141847a5
7 changed files with 97 additions and 19 deletions
@@ -299,6 +299,9 @@ export type IgnoreUnknownProperties<T> = T extends Record<PropertyKey, unknown>
// @public
export const IS_ASYNC_VALIDATION_RESOURCE: unique symbol;
// @public
export function isFieldTree(value: unknown): value is FieldTree<unknown>;
// @public
export interface ItemFieldContext<TValue> extends ChildFieldContext<TValue> {
readonly index: Signal<number>;
+1
View File
@@ -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';
@@ -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<unknown> {
return typeof value === 'function' && (value as any)[FIELD_TREE] === true;
}
+17
View File
@@ -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');
+5 -13
View File
@@ -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<E extends ValidationError = ValidationError> =
| ValidationSuccess
| OneOrMany<E>;
ValidationSuccess | OneOrMany<E>;
/**
* An asynchronous validation result where all errors explicitly define their target field.
@@ -195,8 +190,7 @@ export type ValidationResult<E extends ValidationError = ValidationError> =
* @publicApi 22.0
*/
export type AsyncValidationResult<E extends ValidationError = ValidationError> =
| ValidationResult<E>
| 'pending';
ValidationResult<E> | 'pending';
/**
* A field accessor function that returns the state of the field.
@@ -783,8 +777,7 @@ export type SchemaPathTree<TModel, TPathKind extends PathKind = PathKind.Root> =
* @publicApi 22.0
*/
export type MaybeSchemaPathTree<TModel, TPathKind extends PathKind = PathKind.Root> =
| (TModel & undefined)
| SchemaPathTree<Exclude<TModel, undefined>, TPathKind>;
(TModel & undefined) | SchemaPathTree<Exclude<TModel, undefined>, TPathKind>;
/**
* A reusable schema that defines behavior and rules for a form.
@@ -872,8 +865,7 @@ export type SchemaFn<TModel, TPathKind extends PathKind = PathKind.Root> = (
* @publicApi 22.0
*/
export type SchemaOrSchemaFn<TModel, TPathKind extends PathKind = PathKind.Root> =
| Schema<TModel>
| SchemaFn<TModel, TPathKind>;
Schema<TModel> | SchemaFn<TModel, TPathKind>;
/**
* A function that receives the `FieldContext` for the field the logic is bound to and returns
+11 -6
View File
@@ -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<T>` 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<string, unknown>) {
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<unknown>).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.
@@ -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);
});
});