上一篇介紹 Signal Forms 時,已經看到可以透過 Schema 定義欄位驗證,例如 required()、email()、minLength()。
這些規則只需要目前欄位的值就能判斷結果,但有些情況還需要參考其他欄位,例如:
這類需要參考其他欄位的驗證,就是 Cross-field Validation。

這篇先從確認密碼的案例開始,再補充欄位之間的相依關係,以及 touched()、debounce() 分別會影響什麼。
假設使用者正在設定新密碼,表單有 New Password 和 Confirm Password:
interface PasswordModel {
newPassword: string;
confirmPassword: string;
}
readonly passwordModel = signal<PasswordModel>({
newPassword: '',
confirmPassword: '',
});
confirmPassword 不能只看自己的值,還需要和 newPassword 比較。
這時可以使用 validate() 自訂驗證規則:
import {
form,
minLength,
required,
schema,
validate,
} from '@angular/forms/signals';
const passwordSchema = schema<PasswordModel>(path => {
required(path.newPassword, {
message: '請輸入新密碼',
});
minLength(path.newPassword, 8, {
message: '密碼至少需要 8 個字元',
});
required(path.confirmPassword, {
message: '請再次輸入新密碼',
});
validate(
path.confirmPassword,
({ value, valueOf }) => {
const newPassword = valueOf(path.newPassword);
const confirmPassword = value();
if (newPassword !== confirmPassword) {
return {
kind: 'passwordMismatch',
message: '兩次輸入的密碼不一致',
};
}
return null;
}
);
});
readonly passwordForm = form(
this.passwordModel,
passwordSchema
);
這條驗證掛在 path.confirmPassword,所以 value() 取得的是目前 confirmPassword 的值;如果還需要讀取其他欄位,就可以使用 valueOf()。
const newPassword = valueOf(path.newPassword);
兩個密碼不同時回傳 Validation Error,驗證通過則回傳 null。

錯誤會反映在 Confirm Password 的 Field State:
<input
type="password"
[formField]="passwordForm.newPassword"
/>
<input
type="password"
[formField]="passwordForm.confirmPassword"
/>
@if (
passwordForm.confirmPassword().touched() &&
passwordForm.confirmPassword().invalid()
) {
@for (
error of passwordForm.confirmPassword().errors();
track error.kind
) {
<p>{{ error.message }}</p>
}
}
valueOf() 不只是取得另一個欄位的值,也會讓這個欄位成為驗證規則的相依來源。
假設一開始:
New Password 12345678
Confirm Password 12345678
兩個密碼相同,驗證會通過。
接著只修改 New Password:
New Password abcdefgh
Confirm Password 12345678
即使 Confirm Password 沒有修改,驗證結果還是會重新計算。
因為這條規則透過 valueOf(path.newPassword) 讀取了 newPassword,所以 newPassword 改變時,這條驗證也會重新執行,不需要另外監聽欄位變化。

前面的 validate() Callback 會拿到 FieldContext,可以取得目前欄位或其他欄位的資訊:
value():取得目前正在驗證的欄位值valueOf():取得其他欄位的值stateOf():取得其他欄位的 Field StatefieldTreeOf():取得其他欄位的 FieldTree如果驗證邏輯除了值以外,還需要知道其他欄位目前是不是 invalid()、touched() 等狀態,可以使用 stateOf()。

fieldTreeOf() 可以取得其他欄位對應的 FieldTree。
例如表單裡有巢狀的 Address:
interface UserModel {
name: string;
address: {
city: string;
zipCode: string;
};
}
驗證時如果需要取得整個 address 的 FieldTree,可以使用:
validate(path.name, ({ fieldTreeOf }) => {
const addressTree = fieldTreeOf(path.address);
// addressTree.city
// addressTree.zipCode
return null;
});
這時拿到的是 address 這組欄位的 FieldTree,而不是單一欄位值。
像確認密碼、日期區間這類只需要比較值的情況,通常使用 valueOf() 就足夠了。
Cross-field Validation 不只會出現在密碼欄位。
例如活動表單有開始日期和結束日期:
interface EventModel {
startDate: Date;
endDate: Date;
}
readonly eventModel = signal<EventModel>({
startDate: new Date('2026-10-01'),
endDate: new Date('2026-10-02'),
});
const eventSchema = schema<EventModel>(path => {
validate(
path.endDate,
({ value, valueOf }) => {
const startDate = valueOf(path.startDate);
const endDate = value();
if (endDate < startDate) {
return {
kind: 'invalidDateRange',
message: '結束日期不能早於開始日期',
};
}
return null;
}
);
});
這次規則掛在 endDate,value() 取得 End Date,valueOf(path.startDate) 則取得 Start Date。

前面的 Template 會同時判斷:
@if (
passwordForm.confirmPassword().touched() &&
passwordForm.confirmPassword().invalid()
) {
...
}
這裡是在處理兩件不同的事:
invalid():代表目前的驗證結果touched():表示使用者是否已經操作過這個欄位欄位值更新到 Form Model 後,驗證規則就會重新計算。即使欄位已經是 Invalid,只要還沒有 Touched,畫面仍然可以先不顯示錯誤。
例如 Confirm Password 一開始是空的,required() 已經可以判斷它沒有通過驗證,但畫面還要求 touched() 必須是 true,所以錯誤訊息不會一開始就出現。
等使用者進入欄位再離開後,touched() 會變成 true。如果這時欄位仍然是 Invalid,錯誤訊息才會顯示。
touched() 不會影響 Validator 是否執行,主要是用來控制驗證結果什麼時候顯示在畫面上。
Signal Forms 預設會隨著使用者輸入更新欄位值,行為接近 onInput。
如果不希望每輸入一個字元就立刻把值同步到 Form Model,可以使用 debounce() 調整更新時機。
例如希望等使用者離開 Email 欄位後再更新:
import {
debounce,
email,
required,
schema,
} from '@angular/forms/signals';
const registerSchema = schema<RegisterModel>(path => {
debounce(path.email, 'blur');
required(path.email, {
message: 'Email 為必填',
});
email(path.email, {
message: 'Email 格式錯誤',
});
});
使用 debounce(path.email, 'blur') 後,輸入時不會立刻更新 Form Model,而是等欄位 Blur 後才同步新的值,相關驗證也會跟著重新計算。
除了 'blur',也可以指定等待時間:
debounce(path.email, 300);
這時會等使用者停止輸入一段時間後,再更新 Form Model。
更新方式大致可以整理成:
onInput
debounce(path, 'blur'):Blur 後才更新 Form Modeldebounce(path, 300):停止輸入一段時間後再更新 Form Model這裡的 debounce() 控制的是欄位值更新到 Form Model 的時機,和前面用來控制錯誤訊息顯示的 touched() 是兩件不同的事。
如果 Cross-field Validation 的欄位也設定 debounce(),驗證會等新的值同步到 Model 後再重新計算。
例如使用:
debounce(path.confirmPassword, 'blur');
Confirm Password 會等到 Blur 後才更新,再依照新的值重新執行 Cross-field Validation。

Cross-field Validation 適合處理需要同時參考多個欄位的驗證,例如確認密碼、日期區間。透過 valueOf() 讀取其他欄位後,這些欄位也會成為驗證的相依來源,不需要再另外監聽欄位變化。
除了欄位之間的相依,表單裡還有幾個容易混在一起的時機:
invalid():目前的驗證結果touched():欄位是否已經被操作,常用來控制錯誤訊息顯示debounce():控制欄位值什麼時候同步到 Form ModelSignal Forms 預設會隨輸入更新欄位值,也可以透過 debounce() 改成 Blur 後或等待一段時間再更新。
把驗證結果、錯誤顯示和值更新時機分開後,Cross-field Validation 的處理會更清楚,也比較不需要額外維護其他狀態。