>();
+}
+
+```
+
+### Displaying name and email in Friend's template
+
+```html
+
+@let friend = this.friend();
+
+
+ Name
+
+ @if(!friend.name().valid()){
+ {{ friend.name().errors()[0].kind}}
+ }
+
+
+
+
+ Email
+
+ @if(!friend.email().valid()){
+ {{ friend.email().errors()[0].kind}}
+ }
+
+
+```
+
+### Applying the friend schema to Array elements
+
+We can use the `applyEach` rule within our main form definition to apply the `friendSchema` to each element of the `friends` array.
+
+```typescript
+// feedback.ts
+import {
+ /* ... */
+ applyEach,
+} from 'google3/experimental/angularsignalforms';
+
+import { friendSchema } from './friend';
+
+/* ... */
+export class FeedbackComponent {
+ /* ... */
+ readonly form = form(this.data, (path) => {
+ /* ... */
+ applyEach(path.friends, friendSchema);
+ });
+}
+```
+
+### Displaying friend list in the template
+
+First, we need to import `FriendComponent` into `FeedbackComponent`:
+
+```typescript
+// feedback.ts
+import {FriendComponent} from './friend';
+
+@Component({
+ /* ... */
+ imports: [
+ /* ... */
+ FriendComponent
+ ],
+})
+export class FeedbackComponent {/* ... */}
+```
+
+Now, let's add the UI elements to the `FeedbackComponent` template. First, the `recommendToFriends` checkbox:
+
+```html
+
+
+
+ Recommend to friends
+
+
+```
+
+Then, we'll display the list of friends, but only when the checkbox is checked.
+
+```html
+
+@if (form.recommendToFriends().value()) {
+ @for (friend of form.friends; track friend) {
+
+ }
+}
+```
+
+### Hiding
+
+The current setup works, but there's a small issue.
+If we create a friend with an error, and then hide it, the validation would still run, and the form would be marked as invalid.
+
+We can solve it by using `hidden` with a predicate to disabled the validation.
+
+```typescript
+// feedback.ts
+import {
+ /* ... */
+ applyEach,
+ hidden
+} from 'google3/experimental/angularsignalforms';
+
+import { friendSchema } from './friend';
+
+/* ... */
+export class FeedbackComponent {
+ /* ... */
+ readonly form = form(this.data, (path) => {
+ /* ... */
+ applyEach(path.friends, friendSchema);
+ // Doesn't actually hide anything in the UI.
+ hidden(path.friends, ({valueOf}) => {
+ return valueOf(path.recommendToFriends) === false ;
+ });
+ });
+}
+```
+
+> it's important to note, that `hidden` doesn't actually hide fields in the template, just disables validation.
+
+### Conditionally enabling/disabling validation with applyWhen
+
+Sometimes we want to apply multiple rules based only if certain condition is true.
+
+For this we can use `applyWhen`.
+
+Let's look at an unrelated example, where we want to apply different rules depending on whether a pet is a cat or a dog.
+
+```typescript
+// unrelated-form.ts
+form(this.pet, (pet: FieldPath) => {
+ // Applies for all pets
+ required(pet.cute);
+
+ // Rules that only apply for dogs.
+ applyWhen(
+ pet,
+ ({value}) => value().type === 'dog',
+ (pathWhenTrue) => {
+ // Only required for dogs, but can be entered for cats
+ required(pathWhenTrue.walksPerDay);
+ // Doesn't apply for dogs
+ hidden(pathWhenTrue.purringIntensity);
+ }
+ );
+
+ applyWhen(
+ pet,
+ ({value}) => value().type === 'cat',
+ (pathWhenTrue) => {
+ // Those rules only apply for cats.
+ required(pathWhenTrue.a);
+ validate(pathWhenTrue.b, /* validation rules */);
+ applyEach(pathWhenTrue, /* array rules */);
+ applyWhen(/* we can even have nested apply whens. */);
+ }
+ );
+
+});
+```
+
+In our case, we could use applyWhen instead of hidden (although it might be an overkill for just one rule)
+
+It's also important to not use closured path, but use the one provided by the function:
+
+```typescript
+// feedback.ts
+/* ... */
+export class FeedbackComponent {
+ /* ... */
+ readonly form = form(this.data, (path) => {
+ /* ... other rules ... */
+ applyWhen(
+ path,
+ ({value}) => value().recommendToFriends,
+ (pathWhenTrue) => {
+ applyEach(pathWhenTrue.friends, friendSchema);
+ // 🚨 👮 🚓 You have to use nested path
+ // This produces a Runtime error:
+ applyEach(path /*has to be pathWhenTrue*/.friends, friendSchema);
+ // ✅ This works
+ applyEach(pathWhenTrue.friends, friendSchema);
+ }
+ );
+ });
+}
+```
+
+> `pathWhenTrue` could also just be called path, it's a stylistic chose.
+
+Now, `friendSchema` validation rules will only apply when `recommendToFriends` is true.
+
+### Adding items to the array
+
+Let's allow the user to add a new friend to the list.
+
+```typescript
+// feedback.ts
+export class FeedbackComponent {
+ /* ... */
+ addFriend() {
+ // value is a writable signal.
+ this.form.friends().value.update(
+ (f) => [...f, {name: '', email: ''}]
+ );
+ }
+}
+
+```
+
+Now, add the button to the template inside the `@if` block:
+
+```html
+
+@if (form.recommendToFriends().value()) {
+ @for (friend of form.friends; track friend) {
+
+ }
+
+
+ Add Friend
+
+}
+```
+
+## Submitting the form
+
+To handle form submission, use the `submit` function, passing it your form instance and an async submission handler.
+
+```typescript
+// feedback.ts
+import {
+ /* ... */
+ submit,
+} from 'google3/experimental/angularsignalforms';
+
+/* ... */
+export class FeedbackComponent {
+ /* ... */
+ submit() {
+ submit(this.form, async () => {
+ /* Do your async stuff here */
+ });
+ }
+}
+```
+
+### Handling submission errors
+
+You can return a list of server errors and map them to appropriate field here as well.
+
+```typescript
+// feedback.ts
+/* ... */
+export class FeedbackComponent {
+ /* ... */
+ submit() {
+ submit(this.form, async () => {
+ return Promise.resolve([
+ {
+ field: this.form.name,
+ error: {kind: 'notUnique'},
+ },
+ ]);
+ });
+ }
+}
+
+```
+
+## The end
+
+This marks the end of the tutorial. Let's take a look at the complete form definition consolidating all the rules we've added:
+
+```typescript
+// feedback.ts
+/* ... */
+export class FeedbackComponent {
+ /* ... */
+ readonly form = form(this.data, (path: FieldPath) => {
+ required(path.name);
+
+ required(path.email);
+ validate(path.email, emailValidator);
+
+ required(path.password);
+ required(path.confirmationPassword);
+
+ disabled(path.feedback, ({valueOf}) => {
+ return valueOf(path.rating) > 4;
+ });
+ required(path.feedback);
+
+ validate(path.confirmationPassword, ({value, valueOf}) => {
+ return value() === valueOf(path.password)
+ ? undefined
+ : {kind: 'confirmationPassword'};
+ })
+
+ applyWhen(
+ path,
+ ({value}) => value().recommendToFriends,
+ (pathWhenTrue) => {
+ applyEach(pathWhenTrue.friends, friendSchema);
+ },
+ );
+ });
+}
+```
diff --git a/packages/forms/experimental/index.ts b/packages/forms/experimental/index.ts
new file mode 100644
index 000000000000..ffb1b7aab415
--- /dev/null
+++ b/packages/forms/experimental/index.ts
@@ -0,0 +1,14 @@
+/**
+ * @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
+ */
+
+// This file is not used to build this module. It is only used during editing
+// by the TypeScript language service and during build for verification. `ngc`
+// replaces this file with production index.ts when it rewrites private symbol
+// names.
+
+export * from './public_api';
diff --git a/packages/forms/experimental/public_api.ts b/packages/forms/experimental/public_api.ts
new file mode 100644
index 000000000000..62001d1eca86
--- /dev/null
+++ b/packages/forms/experimental/public_api.ts
@@ -0,0 +1,23 @@
+/**
+ * @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
+ */
+
+/**
+ * @module
+ * @description
+ * Entry point for all public APIs of this package.
+ */
+export * from './src/controls/interop_ng_control';
+export * from './src/controls/control';
+export * from './src/api/structure';
+export * from './src/api/logic';
+export * from './src/api/types';
+export * from './src/api/control';
+export * from './src/api/metadata';
+export * from './src/api/validators';
+export * from './src/api/async';
+export * from './src/api/data';
diff --git a/packages/forms/experimental/src/api/async.ts b/packages/forms/experimental/src/api/async.ts
new file mode 100644
index 000000000000..bf5a61a2ab42
--- /dev/null
+++ b/packages/forms/experimental/src/api/async.ts
@@ -0,0 +1,89 @@
+/**
+ * @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.io/license
+ */
+
+import {httpResource, HttpResourceOptions, HttpResourceRequest} from '@angular/common/http';
+import {ResourceRef, Signal} from '@angular/core';
+import {FieldNode} from '../field_node';
+import {FieldPathNode} from '../path_node';
+import {assertPathIsCurrent} from '../schema';
+import {defineResource} from './data';
+import {FieldContext, FieldPath, FormTreeError} from './types';
+
+export interface AsyncValidatorOptions {
+ readonly params: (ctx: FieldContext) => TRequest;
+ readonly factory: (req: Signal) => ResourceRef;
+ readonly errors: (
+ data: TData,
+ ctx: FieldContext,
+ ) => FormTreeError | FormTreeError[] | undefined;
+}
+
+export function validateAsync(
+ path: FieldPath,
+ opts: AsyncValidatorOptions,
+): void {
+ assertPathIsCurrent(path);
+ const pathNode = FieldPathNode.unwrapFieldPath(path);
+
+ const dataKey = defineResource(path, {
+ params: (ctx) => {
+ const node = ctx.stateOf(path) as FieldNode;
+ if (node.shouldSkipValidation() || !node.syncValid()) {
+ return undefined;
+ }
+ return opts.params(ctx);
+ },
+ factory: opts.factory,
+ });
+
+ pathNode.logic.asyncErrors.push((ctx) => {
+ const res = ctx.state.data(dataKey)!;
+ switch (res.status()) {
+ case 'idle':
+ return undefined;
+ case 'loading':
+ case 'reloading':
+ return 'pending';
+ case 'resolved':
+ case 'local':
+ if (!res.hasValue()) {
+ return undefined;
+ }
+ return opts.errors(res.value()!, ctx);
+ case 'error':
+ // Throw the resource's error:
+ throw res.error();
+ }
+ });
+}
+
+export function validateHttp(
+ path: FieldPath,
+ opts: {
+ request: (ctx: FieldContext) => string | undefined;
+ errors: (data: TData, ctx: FieldContext) => FormTreeError | FormTreeError[] | undefined;
+ options?: HttpResourceOptions;
+ },
+): void;
+
+export function validateHttp(
+ path: FieldPath,
+ opts: {
+ request: (ctx: FieldContext) => HttpResourceRequest | undefined;
+ errors: (data: TData, ctx: FieldContext) => FormTreeError | FormTreeError[] | undefined;
+ options?: HttpResourceOptions;
+ },
+): void;
+
+export function validateHttp(path: FieldPath, opts: any) {
+ validateAsync(path, {
+ params: opts.request,
+ factory: (request: Signal) => httpResource(request, opts.options),
+ errors: opts.errors,
+ });
+}
diff --git a/packages/forms/experimental/src/api/control.ts b/packages/forms/experimental/src/api/control.ts
new file mode 100644
index 000000000000..0288661d366b
--- /dev/null
+++ b/packages/forms/experimental/src/api/control.ts
@@ -0,0 +1,21 @@
+/**
+ * @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.io/license
+ */
+
+import {InputSignal, ModelSignal, OutputRef} from '@angular/core';
+import {FormError} from './types';
+
+export interface FormUiControl {
+ readonly value: ModelSignal;
+ readonly errors?: InputSignal;
+ readonly disabled?: InputSignal;
+ readonly readonly?: InputSignal;
+ readonly valid?: InputSignal;
+ readonly touched?: InputSignal;
+
+ readonly touch?: OutputRef;
+}
diff --git a/packages/forms/experimental/src/api/data.ts b/packages/forms/experimental/src/api/data.ts
new file mode 100644
index 000000000000..14903d539b31
--- /dev/null
+++ b/packages/forms/experimental/src/api/data.ts
@@ -0,0 +1,74 @@
+/**
+ * @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.io/license
+ */
+import {computed, Resource, ResourceRef, ResourceStatus, signal, Signal} from '@angular/core';
+import type {FieldContext, FieldPath, LogicFn} from './types';
+import {assertPathIsCurrent} from '../schema';
+import {FieldPathNode} from '../path_node';
+
+export class DataKey {
+ /** @internal */
+ protected __phantom!: TValue;
+}
+
+export interface DefineOptions {
+ readonly asKey?: DataKey;
+}
+
+export function define(
+ path: FieldPath,
+ factory: (ctx: FieldContext) => TData,
+ opts?: DefineOptions,
+): DataKey {
+ assertPathIsCurrent(path);
+ const key = opts?.asKey ?? new DataKey>();
+ const pathNode = FieldPathNode.unwrapFieldPath(path);
+
+ pathNode.logic.dataFactories.set(key, factory as (ctx: FieldContext) => unknown);
+ return key as DataKey;
+}
+
+export function defineComputed(
+ path: FieldPath,
+ fn: LogicFn,
+ opts?: DefineOptions>,
+): DataKey> {
+ assertPathIsCurrent(path);
+ const key = opts?.asKey ?? new DataKey>();
+
+ const pathNode = FieldPathNode.unwrapFieldPath(path);
+ if (pathNode.logic.dataFactories.has(key)) {
+ // TODO: name of the key?
+ throw new Error(`Can't define data twice for the same key`);
+ }
+ pathNode.logic.dataFactories.set(key, (ctx) => computed(() => fn(ctx as FieldContext)));
+ return key;
+}
+
+export interface DefineResourceOptions extends DefineOptions {
+ params: (ctx: FieldContext) => TRequest;
+ factory: (req: Signal) => ResourceRef;
+}
+
+export function defineResource(
+ path: FieldPath,
+ opts: DefineResourceOptions,
+): DataKey> {
+ assertPathIsCurrent(path);
+ const key = opts.asKey ?? new DataKey>();
+
+ const factory = (ctx: FieldContext) => {
+ const params = computed(() => opts.params(ctx as FieldContext));
+ // we can wrap/process the resource here
+ return opts.factory(params);
+ };
+
+ const pathNode = FieldPathNode.unwrapFieldPath(path);
+ pathNode.logic.dataFactories.set(key, factory);
+
+ return key as DataKey>;
+}
diff --git a/packages/forms/experimental/src/api/logic.ts b/packages/forms/experimental/src/api/logic.ts
new file mode 100644
index 000000000000..92fde4cb7e60
--- /dev/null
+++ b/packages/forms/experimental/src/api/logic.ts
@@ -0,0 +1,150 @@
+/**
+ * @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.io/license
+ */
+
+import {MetadataKey} from '../api/metadata';
+import {FieldPathNode} from '../path_node';
+import {assertPathIsCurrent} from '../schema';
+import type {FieldPath, LogicFn, TreeValidator, Validator} from './types';
+
+/**
+ * Adds logic to a field to conditionally disable it.
+ *
+ * @param path The target path to add the disabled logic to.
+ * @param logic A `LogicFn` that returns `true` when the field is disabled.
+ * @template T The data type of the field the logic is being added to.
+ */
+export function disabled(
+ path: FieldPath,
+ logic: NoInfer> = () => true,
+): void {
+ assertPathIsCurrent(path);
+
+ const pathNode = FieldPathNode.unwrapFieldPath(path);
+ pathNode.logic.disabledReasons.push((ctx) => {
+ const result = logic(ctx);
+ if (!result) {
+ return undefined;
+ }
+ if (typeof result === 'string') {
+ return {
+ field: ctx.field,
+ reason: result,
+ };
+ }
+ return {field: ctx.field};
+ });
+}
+
+/**
+ * Adds logic to a field to conditionally make it readonly.
+ *
+ * @param path The target path to make readonly.
+ * @param logic A `LogicFn` that returns `true` when the field is readonly.
+ * @template T The data type of the field the logic is being added to.
+ */
+export function readonly(path: FieldPath, logic: NoInfer> = () => true) {
+ assertPathIsCurrent(path);
+
+ const pathNode = FieldPathNode.unwrapFieldPath(path);
+ pathNode.logic.readonly.push(logic);
+}
+
+/**
+ * Adds logic to a field to conditionally hide it. A hidden field does not contribute to the
+ * validation, touched/dirty, or other state of its parent field.
+ *
+ * @param path The target path to add the hidden logic to.
+ * @param logic A `LogicFn` that returns `true` when the field is hidden.
+ * @template T The data type of the field the logic is being added to.
+ */
+export function hidden(path: FieldPath, logic: NoInfer>): void {
+ assertPathIsCurrent(path);
+
+ const pathNode = FieldPathNode.unwrapFieldPath(path);
+ pathNode.logic.hidden.push(logic);
+}
+
+/**
+ * Adds logic to a field to conditionally add validation errors to it.
+ *
+ * @param path The target path to add the validation logic to.
+ * @param logic A `Validator` that returns the current validation errors.
+ * @template T The data type of the field the logic is being added to.
+ */
+export function validate(path: FieldPath, logic: NoInfer>): void {
+ assertPathIsCurrent(path);
+
+ const pathNode = FieldPathNode.unwrapFieldPath(path);
+ pathNode.logic.syncErrors.push(logic);
+}
+
+export function validateTree(path: FieldPath, logic: NoInfer>): void {
+ assertPathIsCurrent(path);
+
+ const pathNode = FieldPathNode.unwrapFieldPath(path);
+ pathNode.logic.syncTreeErrors.push(logic);
+}
+
+/**
+ * Adds metadata to a field.
+ *
+ * @param path The target path to add metadata to.
+ * @param key The metadata key
+ * @param logic A `LogicFn` that returns the metadata value for the given key.
+ * @template T The data type of the field the logic is being added to.
+ * @template M The type of metadata.
+ */
+export function metadata(
+ path: FieldPath,
+ key: MetadataKey,
+ logic: NoInfer>,
+): void {
+ assertPathIsCurrent(path);
+
+ const pathNode = FieldPathNode.unwrapFieldPath(path);
+ pathNode.logic.getMetadata(key).push(logic);
+}
+
+/**
+ * Adds logic to a field to conditionally add a validation error to it.
+ * The added FormError will be of `kind: 'custom'`
+ *
+ * @param path The target path to add the error logic to.
+ * @param logic A `LogicFn` that returns `true` when the error should be added.
+ * @param message An optional user-facing message to add to the error, or a `LogicFn`
+ * that returns the user-facing message
+ */
+export function error(
+ path: FieldPath,
+ logic: NoInfer>,
+ message?: string | NoInfer>,
+): void {
+ assertPathIsCurrent(path);
+
+ if (typeof message === 'function') {
+ validate(path, (arg) => {
+ return logic(arg)
+ ? {
+ kind: 'custom',
+ message: message(arg),
+ }
+ : undefined;
+ });
+ } else {
+ const err =
+ message === undefined
+ ? {kind: 'custom'}
+ : {
+ kind: 'custom',
+ message,
+ };
+ validate(path, (arg) => {
+ return logic(arg) ? err : undefined;
+ });
+ }
+}
diff --git a/packages/forms/experimental/src/api/metadata.ts b/packages/forms/experimental/src/api/metadata.ts
new file mode 100644
index 000000000000..2139da3f5d1e
--- /dev/null
+++ b/packages/forms/experimental/src/api/metadata.ts
@@ -0,0 +1,45 @@
+/**
+ * @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.io/license
+ */
+
+export class MetadataKey {
+ constructor(
+ readonly defaultValue: () => TValue,
+ readonly merge: (prev: TValue, next: TValue) => TValue,
+ ) {
+ }
+}
+
+export const REQUIRED = new MetadataKey(
+ () => false,
+ (prev, next) => prev || next,
+);
+
+export const MIN = new MetadataKey(
+ () => -Infinity,
+ (prev, next) => Math.max(prev, next),
+);
+
+export const MAX = new MetadataKey(
+ () => +Infinity,
+ (prev, next) => Math.min(prev, next),
+);
+
+export const MIN_LENGTH = new MetadataKey(
+ () => -Infinity,
+ (prev, next) => Math.max(prev, next)
+);
+
+export const MAX_LENGTH = new MetadataKey(
+ () => +Infinity,
+ (prev, next) => Math.min(prev, next)
+);
+
+export const PATTERN = new MetadataKey(
+ () => [],
+ (prev, next) => [...prev, ...next]
+);
diff --git a/packages/forms/experimental/src/api/structure.ts b/packages/forms/experimental/src/api/structure.ts
new file mode 100644
index 000000000000..fc042b426f12
--- /dev/null
+++ b/packages/forms/experimental/src/api/structure.ts
@@ -0,0 +1,306 @@
+/**
+ * @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.io/license
+ */
+
+import {inject, Injector, WritableSignal} from '@angular/core';
+
+import {FieldNode, FormFieldManager} from '../field_node';
+import {FieldPathNode, FieldRootPathNode} from '../path_node';
+import {assertPathIsCurrent, SchemaImpl} from '../schema';
+import type {
+ Field,
+ FieldPath,
+ LogicFn,
+ Schema,
+ SchemaFn,
+ SchemaOrSchemaFn,
+ ServerError,
+} from './types';
+
+export interface FormOptions {
+ injector?: Injector;
+}
+
+/**
+ * Creates a form wrapped around the given model data. A form is represented as simply a `Field` of
+ * the model data.
+ *
+ * `form` uses the given model as the source of truth and *does not* maintain its own copy of the
+ * data. This means that updating the value on a `FieldState` updates the originally passed in model
+ * as well.
+ *
+ * @example ```
+ * const nameModel = signal({first: '', last: ''});
+ * const nameForm = form(nameModel);
+ * nameForm.first().value.set('John');
+ * nameForm().value(); // {first: 'John', last: ''}
+ * nameModel(); // {first: 'John', last: ''}
+ * ```
+ *
+ * The form can also be created with a schema, which is a set of rules that define the logic for the
+ * form. The schema can be either a pre-defined schema created with the `schema` function, or a
+ * function that builds the schema by binding logic to a parts of the field structure.
+ *
+ * @example ```
+ * const nameForm = form(signal({first: '', last: ''}), (name) => {
+ * required(name.first);
+ * error(name.last, ({value}) => !/^[a-z]+$/i.test(value()), 'Alphabet characters only');
+ * });
+ * nameForm().valid(); // false
+ * nameForm().value.set({first: 'John', last: 'Doe'});
+ * nameForm().valid(); // true
+ * ```
+ *
+ * @param model A writable signal that contains the model data for the form. The resulting field
+ * structure will match the shape of the model and any changes to the form data will be written to
+ * the model.
+ * @param options The form options
+ * @return A `Field` representing a form around the data model.
+ * @template The type of the data model.
+ */
+export function form(model: WritableSignal, options?: FormOptions): Field;
+
+/**
+ * Creates a form wrapped around the given model data. A form is represented as simply a `Field` of
+ * the model data.
+ *
+ * `form` uses the given model as the source of truth and *does not* maintain its own copy of the
+ * data. This means that updating the value on a `FieldState` updates the originally passed in model
+ * as well.
+ *
+ * @example ```
+ * const nameModel = signal({first: '', last: ''});
+ * const nameForm = form(nameModel);
+ * nameForm.first().value.set('John');
+ * nameForm().value(); // {first: 'John', last: ''}
+ * nameModel(); // {first: 'John', last: ''}
+ * ```
+ *
+ * The form can also be created with a schema, which is a set of rules that define the logic for the
+ * form. The schema can be either a pre-defined schema created with the `schema` function, or a
+ * function that builds the schema by binding logic to a parts of the field structure.
+ *
+ * @example ```
+ * const nameForm = form(signal({first: '', last: ''}), (name) => {
+ * required(name.first);
+ * error(name.last, ({value}) => !/^[a-z]+$/i.test(value()), 'Alphabet characters only');
+ * });
+ * nameForm().valid(); // false
+ * nameForm().value.set({first: 'John', last: 'Doe'});
+ * nameForm().valid(); // true
+ * ```
+ *
+ * @param model A writable signal that contains the model data for the form. The resulting field
+ * structure will match the shape of the model and any changes to the form data will be written to
+ * the model.
+ * @param schema A schema or a function that binds logic to the form. This can be optionally
+ * included to specify logic for the form (e.g. validation, disabled fields, etc.)
+ * @param options The form options
+ * @return A `Field` representing a form around the data model.
+ * @template The type of the data model.
+ */
+export function form(
+ model: WritableSignal,
+ schema?: NoInfer>,
+ options?: FormOptions,
+): Field;
+
+export function form(...args: any[]): Field {
+ let model: WritableSignal;
+ let schema: SchemaOrSchemaFn | undefined;
+ let options: FormOptions | undefined;
+ if (args.length === 3) {
+ [model, schema, options] = args;
+ } else if (args.length === 2) {
+ if (isSchema(args[1])) {
+ [model, schema] = args;
+ } else {
+ [model, options] = args;
+ }
+ } else {
+ [model] = args;
+ }
+
+ const injector = options?.injector ?? inject(Injector);
+ const pathNode = new FieldRootPathNode(undefined);
+ if (schema !== undefined) {
+ new SchemaImpl(schema).apply(pathNode);
+ }
+ const fieldManager = new FormFieldManager(injector);
+ const fieldRoot = FieldNode.newRoot(fieldManager, model, pathNode);
+ fieldManager.createFieldManagementEffect(fieldRoot);
+
+ return fieldRoot.fieldProxy as Field;
+}
+
+/**
+ * Applies a schema to each item of an array.
+ *
+ * @example ```
+ * const nameSchema = schema<{first: string, last: string}>((name) => {
+ * required(name.first);
+ * required(name.last);
+ * });
+ * const namesForm = form(signal([{first: '', last: ''}]), (names) => {
+ * array(names, nameSchema);
+ * });
+ * ```
+ *
+ * When binding logic to the array items, the `Field` for the array item is passed as an additional
+ * argument. This can be used to reference other properties on the item.
+ *
+ * @example ```
+ * const namesForm = form(signal([{first: '', last: ''}]), (names) => {
+ * array(names, (name) => {
+ * error(
+ * name.last,
+ * (value, nameField) => value === nameField.first().value(),
+ * 'Last name must be different than first name',
+ * );
+ * });
+ * });
+ * ```
+ *
+ * @param path The target path for an array field whose items the schema will be applied to.
+ * @param schema A schema for an element of the array, or function that binds logic to an
+ * element of the array.
+ * @template T The data type of an element in the array.
+ */
+export function applyEach(path: FieldPath, schema: NoInfer>): void {
+ assertPathIsCurrent(path);
+
+ const elementPath = FieldPathNode.unwrapFieldPath(path).element.fieldPathProxy;
+ apply(elementPath, schema);
+}
+
+/**
+ * Applies a predefined schema to a given `FieldPath`.
+ *
+ * @example ```
+ * const nameSchema = schema<{first: string, last: string}>((name) => {
+ * required(name.first);
+ * required(name.last);
+ * });
+ * const profileForm = form(signal({name: {first: '', last: ''}, age: 0}), (profile) => {
+ * apply(profile.name, nameSchema);
+ * });
+ * ```
+ *
+ * @param path The target path to apply the schema to.
+ * @param schema The schema to apply to the property
+ */
+export function apply(path: FieldPath, schema: NoInfer>): void {
+ assertPathIsCurrent(path);
+
+ const pathNode = FieldPathNode.unwrapFieldPath(path);
+ const schemaRootPathNode = new FieldRootPathNode(undefined);
+ new SchemaImpl(schema).apply(schemaRootPathNode);
+ pathNode.mergeIn(schemaRootPathNode);
+}
+
+/**
+ * Conditionally applies a predefined schema to a given `FieldPath`.
+ *
+ * @param path The target path to apply the schema to.
+ * @param logic A `LogicFn` that returns `true` when the schema should be applied.
+ * @param schema The schema to apply to the field when the `logic` function returns `true`.
+ */
+export function applyWhen(
+ path: FieldPath,
+ logic: LogicFn,
+ schema: NoInfer>,
+): void {
+ assertPathIsCurrent(path);
+
+ const pathNode = FieldPathNode.unwrapFieldPath(path);
+ const schemaRootPathNode = new FieldRootPathNode({fn: logic, path});
+ new SchemaImpl(schema).apply(schemaRootPathNode);
+ pathNode.mergeIn(schemaRootPathNode);
+}
+
+/**
+ * Conditionally applies a predefined schema to a given `FieldPath`.
+ *
+ * @param path The target path to apply the schema to.
+ * @param predicate A type guard that accepts a value `T` and returns `true` if `T` is of type
+ * `TNarrowed`.
+ * @param schema The schema to apply to the field when `predicate` returns `true`.
+ */
+export function applyWhenValue(
+ path: FieldPath,
+ predicate: (value: T) => value is TNarrowed,
+ schema: NoInfer>,
+): void;
+/**
+ * Conditionally applies a predefined schema to a given `FieldPath`.
+ *
+ * @param path The target path to apply the schema to.
+ * @param predicate A function that accepts a value `T` and returns `true` when the schema
+ * should be applied.
+ * @param schema The schema to apply to the field when `predicate` returns `true`.
+ */
+export function applyWhenValue(
+ path: FieldPath,
+ predicate: (value: T) => boolean,
+ schema: NoInfer>,
+): void;
+export function applyWhenValue(
+ path: FieldPath,
+ predicate: (value: unknown) => boolean,
+ schema: SchemaOrSchemaFn,
+) {
+ applyWhen(path, ({value}) => predicate(value()), schema);
+}
+
+/**
+ * Submits a given `Field` using the given action function and applies any server errors resulting
+ * from the action to the field. Server errors retured by the `action` will be integrated into the
+ * field as a `FormError` on the sub-field indicated by the `field` property of the server error.
+ *
+ * @example ```
+ * async function registerNewUser(registrationForm: Field<{username: string, password: string}>) {
+ * const result = await myClient.registerNewUser(registrationForm().value());
+ * if (result.errorCode === myClient.ErrorCode.USERNAME_TAKEN) {
+ * return [{
+ * field: registrationForm.username,
+ * error: {kind: 'server', message: 'Username already taken'}
+ * }];
+ * }
+ * return undefined;
+ * }
+ *
+ * const registrationForm = form(signal({username: 'god', password: ''}));
+ * submit(registrationForm, async (f) => {
+ * return registerNewUser(registrationForm);
+ * });
+ * registrationForm.username().errors(); // [{kind: 'server', message: 'Username already taken'}]
+ * ```
+ *
+ * @param f The field to submit.
+ * @param action An asynchronous action used to submit the field. The action may return server
+ * errors.
+ */
+export async function submit(
+ form: Field,
+ action: (form: Field) => Promise,
+) {
+ const api = form() as FieldNode;
+ api.setSubmittedStatus('submitting');
+ const errors = (await action(form)) || [];
+ for (const error of errors) {
+ (error.field() as FieldNode).setServerErrors(error.error);
+ }
+ api.setSubmittedStatus('submitted');
+}
+
+export function schema(fn: SchemaFn): Schema {
+ return fn as unknown as Schema;
+}
+
+function isSchema(obj: unknown): obj is Schema {
+ return typeof obj === 'function';
+}
diff --git a/packages/forms/experimental/src/api/types.ts b/packages/forms/experimental/src/api/types.ts
new file mode 100644
index 000000000000..f067b5cabe93
--- /dev/null
+++ b/packages/forms/experimental/src/api/types.ts
@@ -0,0 +1,239 @@
+/**
+ * @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.io/license
+ */
+
+import {Signal, WritableSignal} from '@angular/core';
+import {DataKey} from './data';
+import {MetadataKey} from './metadata';
+
+/**
+ * Symbol used to retain generic type information when it would otherwise be lost.
+ */
+declare const ɵɵTYPE: unique symbol;
+
+/**
+ * Indicates whether the form is unsubmitted, submitted, or currently submitting.
+ */
+export type SubmittedStatus = 'unsubmitted' | 'submitted' | 'submitting';
+
+export interface DisabledReason {
+ field: Field;
+ reason?: string;
+}
+
+/**
+ * A validation error on a form. All validation errors must have a `kind` that identifies what type
+ * of error it is, and may optionally have a `message` string containing a human-readable error
+ * message.
+ */
+export interface FormError {
+ readonly kind: string;
+ readonly message?: string;
+ readonly field?: never;
+}
+
+export interface FormTreeError extends Omit {
+ readonly field?: Field;
+}
+
+/**
+ * An error that is returned from the server when submitting the form. It contains a reference to
+ * the validation errors as well as a reference to the `Field` node those errors should be
+ * associated with.
+ */
+export interface ServerError {
+ field: Field;
+ error: ValidationResult;
+}
+
+/**
+ * The result of running a validation function. The result may be `undefined` to indicate no errors,
+ * a single `FormError`, or a list of `FormError` which can be used to indicate multiple errors.
+ */
+export type ValidationResult = FormError | FormError[] | undefined;
+
+/**
+ * An object that represents a single field in a form. This includes both primitive value fields
+ * (e.g. fields that contain a `string` or `number`), as well as "grouping fields" that contain
+ * sub-fields. `Field` objects are arranged in a tree whose structure mimics the structue of the
+ * underlaying data. For example a `Field<{x: number}>` has a property `x` which contains a
+ * `Field`. To access the state associated with a field, call it as a function.
+ *
+ * @template T The type of the data which the field is wrapped around.
+ */
+export type Field = (() => FieldState) &
+ (T extends Array
+ ? Array>
+ : T extends Record
+ ? {[K in keyof T]: Field}
+ : unknown);
+
+/**
+ * Contains all of the state (e.g. value, statuses, metadata) associated with a `Field`, exposed as
+ * signals.
+ */
+export interface FieldState {
+ /**
+ * A writable signal containing the value for this field. Updating this signal will update the
+ * data model that the field is bound to.
+ */
+ readonly value: WritableSignal;
+ /**
+ * A signal indicating whether the field has been touched by the user.
+ */
+ readonly touched: Signal;
+ /**
+ * A signal indicating whether field value has been changed by user.
+ */
+ readonly dirty: Signal;
+ /**
+ * A signal indicating whether the field is currently disabled.
+ */
+ readonly disabled: Signal;
+ /**
+ * A signal containing the reasons why the field is currently disabled.
+ */
+ readonly disabledReasons: Signal;
+ /**
+ * A signal indicating whether the field is currently readonly.
+ */
+ readonly readonly: Signal;
+ /**
+ * A signal containing the current errors for the field.
+ */
+ readonly errors: Signal;
+ /**
+ * A signal containing the current errors for the field.
+ */
+ readonly syncErrors: Signal;
+ /**
+ * A signal indicating whether the field's value is currently valid.
+ *
+ * Note: `valid()` is not the same as `!invalid()`.
+ * - `valid()` is `true` when there are no validation errors *and* no pending validators.
+ * - `invalid()` is `true` when there are validation errors, regardless of pending validators.
+ *
+ * Ex: consider the situation where a field has 3 validators, 2 of which have no errors and 1 of
+ * which is still pending. In this case `valid()` is `false` because of the pending validator.
+ * However `invalid()` is also `false` because there are no errors.
+ */
+ readonly valid: Signal;
+ /**
+ * A signal indicating whether the field's value is currently invalid.
+ *
+ * Note: `invalid()` is not the same as `!valid()`.
+ * - `invalid()` is `true` when there are validation errors, regardless of pending validators.
+ * - `valid()` is `true` when there are no validation errors *and* no pending validators.
+ *
+ * Ex: consider the situation where a field has 3 validators, 2 of which have no errors and 1 of
+ * which is still pending. In this case `invalid()` is `false` because there are no errors.
+ * However `valid()` is also `false` because of the pending validator.
+ */
+ readonly invalid: Signal;
+ /**
+ * Whether there are any validators still pending for this field.
+ */
+ readonly pending: Signal;
+ /**
+ * A signal indicating whether the field's value is currently valid.
+ */
+ readonly syncValid: Signal