시그널 폼은 폼이 어떻게 구성되는지 와 어떻게 실행되는지 두 계층을 분리하는 구조로 설계되었습니다.
스키마 함수를 form() 인자로 전달하면, 스키마 함수는 폼을 생성하면서 한 번만 실행됩니다.
스키마 함수의 역할은 어떤 필드가 어떤 유효성 검사를 해야 하는지, 어떤 필드가 비활성화되는지, 어떤 필드가 다른 필드에 영향을 받는지 등 폼의 로직 트리를 구성하는 것입니다.
이 계층이 폼의 구조 계층(structural layer) 입니다.
스키마 함수 안으로 들어가면, disabled()나 validate()와 같은 규칙 함수를 실행할 수 있습니다.
이런 규칙 함수들은 참조 필드의 값이 변경되었을 때마다 다시 실행되는 반응형 로직을 인자로 받습니다.
disabled()나 required()와 같은 조건부 규칙은 옵션을 지정할 수 있기 때문에, 해당 조건을 지정하기 위해 when 함수를 추가할 수도 있습니다.
이렇게 규칙을 지정하는 것은 폼이 실행시점에 어떻게 동작할지 동작 계층(behavioral layer) 을 지정하는 것입니다.
contactForm = form(this.contactModel, (schemaPath) => {
// 스키마 함수: 폼을 생성할 때 한 번 실행됩니다.
required(schemaPath.name);
disabled(schemaPath.couponCode, {when: ({valueOf}) => valueOf(schemaPath.total) < 50});
// ^^^ 반응형 로직: total 값이 변경될 때마다 다시 계산합니다.
});
스키마를 구성할 때 이런 구분은 중요합니다.
apply(), applyWhen(), schema()와 같은 함수는 모두 구조 계층에서 동작하기 때문입니다.
스키마는 어떤 규칙이 존재하는지, 규칙이 활성화되었는지 여부 를 조작하는 한편, 규칙 함수는 해당 규칙이 무엇을 평가하는지 정의합니다.
스키마 재사용하기: schema()
폼이 여러 개인데 다루는 데이터 모양이 같고 유효성 검사 규칙도 같다면, 스키마를 재사용하기 위해 schema() 함수를 사용할 수 있습니다.
import {schema, required, minLength} from '@angular/forms/signals';
const nameSchema = schema<{first: string; last: string}>((name) => {
required(name.first);
required(name.last);
minLength(name.first, 2);
minLength(name.last, 2);
});
schema() 함수는 함수를 래핑(wrap)해서 재사용 가능한 Schema<T> 객체로 변환합니다.
이렇게 만든 Schema<T> 객체는 여러 폼에 사용할 수 있으며, 보통 스키마 함수와 마찬가지로 폼마다 한 번씩 실행됩니다.
팁: 규칙이 한 곳에만 존재하는 경우, 인라인 스키마 함수를 사용해도 동일한 효과를 얻을 수 있습니다.
한 스키마를 여러 폼에 사용하거나, 여러 경로에 동일한 스키마를 적용하려면 schema() 함수를 사용하세요.
Schema 객체는 폼이 컴파일될 때마다 캐싱됩니다.
스키마 적용하기: apply()
apply() 함수를 사용하면 폼의 특정 경로에 스키마를 적용할 수 있습니다.
apply()를 호출하면 스키마는 지정한 하위 경로 내부의 필드만 볼 수 있도록 범위가 제한된 경로를 전달받습니다:
import {apply, form} from '@angular/forms/signals';
profileForm = form(this.profileModel, (schemaPath) => {
apply(schemaPath.name, nameSchema);
});
registrationForm = form(this.registrationModel, (schemaPath) => {
apply(schemaPath.name, nameSchema);
});
조건부 스키마 적용: applyWhen()
참고: 폼 로직 추가하기 문서에서는 인라인 로직을 사용하는 조건부 규칙을 다루면서 applyWhen()을 소개했습니다.
이번 섹션에서는 스키마를 재사용하면서 applyWhen()을 사용해 봅시다.
어떤 규칙은 특정 조건에서만 적용되어야 하는 경우가 있습니다. 예를 들면, 우편번호 필드는 사용자가 미국을 골랐을 때만 필요할 수 있습니다.
applyWhen() 함수는 상태에 반응하는 방식으로 스키마를 조건부로 적용합니다.
이 함수는 인자를 3개 받습니다:
- 스키마가 적용될 경로
- 스키마가 적용되는 조건이면
true를 반환하는 반응형 로직 함수 - 조건부 규칙을 포함하는 스키마나 스키마 함수
import {form, applyWhen, required, pattern} from '@angular/forms/signals';
addressForm = form(this.addressModel, (schemaPath) => {
applyWhen(
schemaPath,
({valueOf}) => valueOf(schemaPath.country) === 'US',
(schemaPath) => {
required(schemaPath.zipCode);
pattern(schemaPath.zipCode, /^\d{5}(-\d{4})?$/);
},
);
});
로직 함수는 FieldContext를 인자로 받는데, 이 인자를 통해 value, valueOf, stateOf 등과 같은 반응형 헬퍼를 활용할 수 있습니다.
이 헬퍼 함수들은 반응형으로 동작하기 때문에, 참조하는 시그널이 변경되면 조건을 다시 평가합니다.
조건 함수가 false 값을 반환하면 스키마에 포함된 규칙은 비활성화됩니다.
그리고 조건 함수가 true를 반환하면 스키마가 활성화됩니다.
스키마는 그 자체로 구조 역할을 하며, 폼이 생성될 때 한 번 실행됩니다. 결국 규칙이 활성화 되는지 여부는 시그널이 제어하며, 규칙이 있다는 것 자체는 중요하지 않습니다.
조건부 스키마 안에서는 해당 스키마 함수에 전달된 범위 한정 경로 매개변수를 사용하세요. 외부 스키마에서 얻은 경로는 중첩 스키마 내부에서 사용할 수 없습니다.
스키마 재사용 조합하기: applyWhen()
applyWhen() 함수는 Schema 객체를 인자로 받기 때문에 schema() 함수와 함께 사용해서 스키마를 재사용하면서 조건부로 적용할 수 있습니다:
const usZipCodeSchema = schema<{zipCode: string}>((address) => {
required(address.zipCode);
pattern(address.zipCode, /^\d{5}(-\d{4})?$/);
});
const caPostalCodeSchema = schema<{postalCode: string}>((address) => {
required(address.postalCode);
pattern(address.postalCode, /^[A-Z]\d[A-Z] \d[A-Z]\d$/);
});
shippingForm = form(this.shippingModel, (schemaPath) => {
applyWhen(
schemaPath.address,
({valueOf}) => valueOf(schemaPath.country) === 'US',
usZipCodeSchema,
);
applyWhen(
schemaPath.address,
({valueOf}) => valueOf(schemaPath.country) === 'CA',
caPostalCodeSchema,
);
});
참고: 경로 인자가 schemaPath.address지만, 로직 함수는 valueOf(schemaPath.country)에 접근합니다.
왜냐하면 valueOf 헬퍼 함수는 지정된 필드뿐만 아니라 폼 내부의 모든 필드에 접근할 수 있기 때문입니다.
이 패턴은 유효성 검사 로직을 모듈화하는 것입니다. 각 국가의 주소 규칙은 별도 스키마에 존재하며, 사용자가 어떤 국가를 선택하면 해당 스키마가 어떤 규칙을 활성화할지 결정합니다.
타입 좁히기: applyWhenValue()
applyWhenValue() 함수는 특정 필드의 값만 검사하는 방식으로 조건을 단순화합니다.
그래서 FieldContext를 인자로 받는 대신, 필드의 값을 직접 받아서 평가하는 함수를 인자로 받습니다.
applyWhen — 로직 함수는 FieldContext를 인자로 받습니다
applyWhen(schemaPath.payment, ({value}) => value().type === 'credit-card', creditCardSchema);
applyWhenValue — 조건 함수는 인자로 필드 값을 직접 받습니다
applyWhenValue(schemaPath.payment, (payment) => payment.type === 'credit-card', creditCardSchema);
applyWhenValue()는 TypeScript 타입 가드로 사용할 수 있다는 점이 가장 큰 장점입니다.
조건 함수가 타입 가드로 사용되면, 스키마의 타입 인자는 해당 가드 타입으로 좁혀집니다.
이 구조는 유니언 타입을 특정 타입으로 좁혀서 원하는 규칙만 지정하려고 할 때 특히 유용합니다.
import {form, applyWhenValue, required} from '@angular/forms/signals';
interface CreditCard {
type: 'credit-card';
cardNumber: string;
expiry: string;
cvv: string;
}
interface BankTransfer {
type: 'bank-transfer';
accountNumber: string;
routingNumber: string;
}
type PaymentMethod = CreditCard | BankTransfer;
function isCreditCard(value: PaymentMethod): value is CreditCard {
return value.type === 'credit-card';
}
function isBankTransfer(value: PaymentMethod): value is BankTransfer {
return value.type === 'bank-transfer';
}
paymentForm = form(this.paymentModel, (schemaPath) => {
applyWhenValue(schemaPath, isCreditCard, (payment) => {
// TypeScript는 payment가 CreditCard 타입이라고 인식합니다.
required(payment.cardNumber);
required(payment.expiry);
required(payment.cvv);
});
applyWhenValue(schemaPath, isBankTransfer, (payment) => {
// TypeScript는 payment가 BankTransfer 타입이라고 인식합니다.
required(payment.accountNumber);
required(payment.routingNumber);
});
});
타입 가드를 사용하지 않으면 TypeScript는 스키마 함수 안에 어떤 필드가 있는지 알 수 없습니다.
그래서 타입을 좁혀야 신용 카드 로직에서는 payment.cardNumber에 접근할 수 있으며 이체 로직에서 payment.accountNumber에 안전하게 접근할 수 있습니다.
배열 다루기: applyEach()
폼에 객체 배열이 존재하는 경우에는, 개별 항목마다 같은 룰을 적용해야 하는 경우가 많습니다.
이런 경우에는 applyEach() 함수를 사용해서 배열 필드의 항목 개수 제한 없이 개별 항목에 스키마를 적용할 수 있습니다.
import {form, applyEach, required, min} from '@angular/forms/signals';
type LineItem = {name: string; quantity: number};
orderForm = form(this.orderModel, (schemaPath) => {
required(schemaPath.title);
applyEach(schemaPath.items, (item) => {
required(item.name);
min(item.quantity, 1);
});
});
applyEach() 함수에 전달되는 스키마 함수는 배열의 개별 항목을 SchemaPathTree 인자로 받습니다.
스키마 함수 안에 정의된 규칙은 배열의 개별 항목마다 적용되며, 이는 폼 생성 이후에 배열에 항목이 추가되는 경우에도 적용됩니다.
배열에 스키마 활용하기: applyEach()
applyEach() 함수는 Schema 객체를 인자로 받기 때문에, 배열의 개별 항목에 적용될 규칙을 스키마로 만들어서 폼에 적용할 수 있습니다:
const lineItemSchema = schema<LineItem>((item) => {
required(item.name);
min(item.quantity, 1);
});
orderForm = form(this.orderModel, (schemaPath) => {
required(schemaPath.title);
applyEach(schemaPath.items, lineItemSchema);
});
invoiceForm = form(this.invoiceModel, (schemaPath) => {
required(schemaPath.invoiceNumber);
applyEach(schemaPath.lineItems, lineItemSchema);
});
팁: 커스텀 오류 메시지를 활용하는 등 배열 항목에 유효성 검사를 더 적용하려면 유효성 검사 가이드 문서를 참고하세요.
다음 단계
시그널 폼에 대해 더 알아보세요: