검증 라이브러리를 사용할 때 한 가지 당연하게 받아들이는 전제가 있습니다. 검증할 때의 타입과 검증된 결과의 타입이 같다는 것입니다. Zod에서 z.string()으로 검증하면 입력도 string이고 결과도 string입니다. z.number()로 검증하면 입력도 number여야 하고 결과도 number입니다.
그런데 프론트엔드 개발에서는 이 전제가 맞지 않는 경우가 매우 많습니다. HTML form의 <input>은 항상 string을 반환하지만, age 필드는 number로 다루고 싶습니다. URL query parameter page=2의 2는 string으로 들어오지만 number로 쓰고 싶습니다. API 응답의 "2024-01-15T10:30:00Z"는 string이지만 코드에서는 Date 객체로 다루고 싶습니다.
기존에는 이 차이를 수동으로 처리했습니다.
// 파싱할 때
const age = Number(formData.get("age"))
const createdAt = new Date(response.createdAt)
// 다시 보낼 때
const params = { age: String(age) }
const body = { createdAt: createdAt.toISOString() }
필드가 늘어날수록 이 변환 코드도 늘어나고, 파싱과 역변환의 불일치가 버그의 원인이 됩니다. 외부에서 들어오는 형태와 내부에서 사용하는 형태가 다른 것이 오히려 자연스럽고, 이 차이를 스키마 수준에서 관리하는 방법이 Zod 4.1의 z.codec()과 Effect Schema에 있습니다.
Zod는 기본적으로 단일 타입 모델입니다. z.string()의 입력 타입과 출력 타입은 모두 string입니다.
문자열을 숫자로 변환하려면 transform을 씁니다.
const schema = z.string().transform((val) => Number(val))
// Input: string, Output: number
결과 타입은 number가 되지만, 이것은 단방향 변환입니다. number를 다시 string으로 되돌리는 경로가 없습니다. z.coerce.number()도 마찬가지입니다. JavaScript의 Number() 생성자를 입력에 적용한 뒤 number 검증을 하는 것이라 역방향 변환은 지원하지 않습니다.
Zod 4.1부터 z.codec()이 도입되면서 양방향 변환이 가능해졌습니다.
const stringToNumber = z.codec(
z.string(), // input schema (외부 타입)
z.number(), // output schema (내부 타입)
{
decode: (s) => Number(s), // string → number
encode: (n) => String(n), // number → string
}
)
// 디코딩 (외부 → 내부)
stringToNumber.decode("42") // 42 (number)
// 인코딩 (내부 → 외부)
stringToNumber.encode(42) // "42" (string)
z.codec()은 두 개의 스키마와 양방향 변환 함수를 받습니다. .decode()는 외부 타입에서 내부 타입으로, .encode()는 내부 타입에서 외부 타입으로 변환합니다.
.parse()와 .decode()에는 차이가 있습니다. .parse()는 unknown을 받지만 .decode()는 입력 타입이 강타입이라서 컴파일 타임에 타입 오류를 잡을 수 있습니다.
Codec은 일반 스키마처럼 객체 안에서 합성됩니다.
const isoDatetimeToDate = z.codec(
z.iso.datetime(),
z.date(),
{
decode: (s) => new Date(s),
encode: (d) => d.toISOString(),
}
)
const User = z.object({
name: z.string(),
age: stringToNumber,
createdAt: isoDatetimeToDate,
})
// 디코딩
User.decode({
name: "Alice",
age: "30",
createdAt: "2024-01-15T10:30:00.000Z",
})
// { name: "Alice", age: 30, createdAt: Date }
// 인코딩
User.encode({
name: "Alice",
age: 30,
createdAt: new Date("2024-01-15T10:30:00.000Z"),
})
// { name: "Alice", age: "30", createdAt: "2024-01-15T10:30:00.000Z" }
주의할 점이 있습니다. 기존 .transform()과 .encode()는 호환되지 않습니다. transform이 포함된 스키마에서 .encode()를 호출하면 런타임 에러가 발생합니다. 양방향 변환이 필요하면 반드시 z.codec()을 사용해야 합니다.
Zod 공식 문서에는 stringToNumber, isoDatetimeToDate, stringToURL 등 자주 쓰이는 codec 구현 예제가 제공되지만, 이들은 라이브러리에 내장된 것이 아니라 직접 정의해서 사용해야 합니다.
Effect Schema는 같은 문제를 다른 방식으로 접근합니다. 모든 스키마가 기본적으로 양방향입니다.
Effect Schema의 모든 스키마는 세 개의 타입 파라미터를 가집니다.
Schema<Type, Encoded, Context>
never)Schema.String은 Schema<string, string, never>입니다. 입력도 string이고 결과도 string이니 별다를 게 없습니다. 하지만 Schema.NumberFromString은 다릅니다.
import { Schema } from "effect"
// Schema<number, string, never>
// ~~~~~~ ~~~~~~
// Type Encoded
const schema = Schema.NumberFromString
Schema.decodeUnknownSync(schema)("42") // 42 (number)
Schema.encodeSync(schema)(42) // "42" (string)
Encoded는 string이고 Type은 number입니다. 외부에서 "42"라는 문자열이 들어오면 42라는 숫자로 디코딩하고, 반대로 42를 외부로 보낼 때는 "42"로 인코딩합니다.
이 두 타입은 Struct에서 합성됩니다.
const User = Schema.Struct({
name: Schema.String,
age: Schema.NumberFromString,
createdAt: Schema.Date,
})
type UserType = typeof User.Type
// { readonly name: string; readonly age: number; readonly createdAt: Date }
type UserEncoded = typeof User.Encoded
// { readonly name: string; readonly age: string; readonly createdAt: string }
User.Type과 User.Encoded가 다릅니다. age는 내부에서 number지만 외부에서는 string이고, createdAt은 내부에서 Date지만 외부에서는 string입니다. 스키마 하나로 외부 데이터의 형태와 내부 데이터의 형태를 동시에 기술하는 셈입니다.
Zod와의 핵심 차이는 NumberFromString, Date 같은 변환 스키마가 라이브러리에 내장되어 있다는 점입니다. 별도로 codec을 정의하지 않아도 바로 가져다 쓸 수 있습니다.
| Zod (4.1+) | Effect Schema | |
|---|---|---|
| 디코딩 (외부 → 내부) | .parse() / .decode() | Schema.decodeUnknownSync |
| 인코딩 (내부 → 외부) | .encode() | Schema.encodeSync |
| 타입 추출 | z.infer / z.input | Type / Encoded |
| 양방향 설계 | z.codec()으로 명시적 구성 | 모든 스키마의 기본 설계 |
| 내장 변환 스키마 | 예제 제공 (직접 정의 필요) | NumberFromString, Date 등 내장 |
핵심 차이는 기본값입니다. Effect Schema는 모든 스키마가 양방향이고 자주 쓰이는 변환이 내장되어 있습니다. Zod는 양방향이 필요할 때 z.codec()으로 명시적으로 구성합니다. 어느 쪽이 낫다기보다, 접근 방식의 차이입니다.
API 응답을 파싱하는 코드를 생각해봅시다.
const ApiUser = Schema.Struct({
id: Schema.UUID,
name: Schema.String,
createdAt: Schema.Date,
})
// API 응답 파싱 (디코딩)
const user = Schema.decodeUnknownSync(ApiUser)({
id: "550e8400-e29b-41d4-a716-446655440000",
name: "Alice",
createdAt: "2024-01-15T10:30:00Z",
})
// user.createdAt는 Date 객체
// 다시 API로 보내기 (인코딩)
const payload = Schema.encodeSync(ApiUser)(user)
// payload.createdAt는 "2024-01-15T10:30:00.000Z" (string)
디코딩만 있으면 파싱은 됩니다. 하지만 수정된 데이터를 다시 API로 보내야 할 때, 인코딩이 없으면 Date 객체를 수동으로 ISO 문자열로 변환해야 합니다. 필드가 늘어나고 변환이 복잡해질수록 이 수동 작업은 버그의 원인이 됩니다.
URL parameter도 같은 상황입니다.
const SearchParams = Schema.Struct({
page: Schema.NumberFromString,
limit: Schema.NumberFromString,
sort: Schema.Literal("asc", "desc"),
})
// URL에서 파싱 (디코딩)
const params = Schema.decodeUnknownSync(SearchParams)({
page: "2", limit: "20", sort: "asc"
})
// { page: 2, limit: 20, sort: "asc" }
// 다시 URL로 변환 (인코딩)
const query = Schema.encodeSync(SearchParams)(params)
// { page: "2", limit: "20", sort: "asc" }
page를 내부에서는 number로 계산하고, URL로 다시 내보낼 때는 string으로 변환합니다. 스키마 하나로 양방향이 처리됩니다.
기본 제공되는 NumberFromString, Date 외에 직접 변환 스키마를 만들 수 있습니다.
설정값을 "on" / "off" 문자열로 저장하는 경우를 생각해봅시다. 이걸 boolean으로 변환하는 스키마를 만들어봅시다.
const BooleanFromCheckbox = Schema.transform(
Schema.Literal("on", "off"), // Encoded
Schema.Boolean, // Type
{
strict: true,
decode: (s) => s === "on",
encode: (b) => (b ? "on" : "off"),
}
)
Schema.decodeUnknownSync(BooleanFromCheckbox)("on") // true
Schema.encodeSync(BooleanFromCheckbox)(false) // "off"
decode와 encode를 동시에 정의합니다. 어느 방향으로든 변환이 가능합니다.
JSON string으로 저장된 배열을 다루는 경우도 생각해볼 수 있습니다. DB에서 tags를 JSON string으로 저장하는 경우입니다.
const TagsFromJsonString = Schema.transform(
Schema.String,
Schema.Array(Schema.String),
{
strict: true,
decode: (s) => JSON.parse(s),
encode: (arr) => JSON.stringify(arr),
}
)
Schema.decodeUnknownSync(TagsFromJsonString)('["react","typescript"]')
// ["react", "typescript"]
Schema.encodeSync(TagsFromJsonString)(["react", "typescript"])
// '["react","typescript"]'
변환이 항상 성공하지는 않습니다. 잘못된 형식의 문자열이 들어올 수 있습니다.
import { ParseResult, Schema } from "effect"
const IntFromString = Schema.transformOrFail(
Schema.String,
Schema.Number,
{
strict: true,
decode: (input, _options, ast) => {
const parsed = parseInt(input, 10)
if (isNaN(parsed)) {
return ParseResult.fail(
new ParseResult.Type(ast, input, "정수로 변환할 수 없습니다")
)
}
return ParseResult.succeed(parsed)
},
encode: (input) => ParseResult.succeed(input.toString()),
}
)
Schema.decodeUnknownSync(IntFromString)("42") // 42
Schema.decodeUnknownSync(IntFromString)("abc") // ParseError 발생
transformOrFail은 Effect를 반환할 수 있어서 비동기 변환도 가능합니다. 외부 API를 호출해서 검증하는 스키마도 만들 수 있습니다.
Effect Schema의 brand는 구조적 타입에 이름표를 붙입니다.
const UserId = Schema.UUID.pipe(Schema.brand("UserId"))
const OrderId = Schema.UUID.pipe(Schema.brand("OrderId"))
type UserId = typeof UserId.Type // string & Brand<"UserId">
type OrderId = typeof OrderId.Type // string & Brand<"OrderId">
둘 다 런타임에서는 string이지만 타입 레벨에서는 서로 호환되지 않습니다. UserId를 받는 함수에 OrderId를 넘기면 컴파일 에러가 납니다.
이것을 인코딩/디코딩과 조합하면 외부에서는 일반 string, 내부에서는 브랜드 타입인 스키마가 됩니다.
const UserId = Schema.UUID.pipe(Schema.brand("UserId"))
// Encoded: string
// Type: string & Brand<"UserId">
const id = Schema.decodeUnknownSync(UserId)("550e8400-e29b-41d4-a716-446655440000")
// id의 타입은 string & Brand<"UserId">
API에서 받은 string이 자동으로 UserId라는 브랜드 타입이 됩니다. 이후 코드에서는 UserId와 OrderId를 실수로 섞어 쓸 수 없게 됩니다.
Form 데이터를 처리하는 전체적인 예시를 봅시다. HTML form은 모든 값을 string으로 보내기 때문에 인코딩/디코딩의 차이가 가장 명확하게 드러납니다.
const ContactForm = Schema.Struct({
name: Schema.NonEmptyString,
age: Schema.NumberFromString,
email: Schema.String.pipe(Schema.pattern(/^[^@]+@[^@]+\.[^@]+$/)),
subscribe: Schema.transform(
Schema.Literal("on", "off"),
Schema.Boolean,
{
strict: true,
decode: (s) => s === "on",
encode: (b) => (b ? "on" : "off"),
}
),
birthDate: Schema.Date,
})
type FormInput = typeof ContactForm.Encoded
// {
// readonly name: string;
// readonly age: string;
// readonly email: string;
// readonly subscribe: "on" | "off";
// readonly birthDate: string;
// }
type FormData = typeof ContactForm.Type
// {
// readonly name: string;
// readonly age: number;
// readonly email: string;
// readonly subscribe: boolean;
// readonly birthDate: Date;
// }
FormInput은 HTML form이 보내는 그대로의 타입이고, FormData는 비즈니스 로직에서 사용하는 타입입니다. 스키마 하나로 두 타입을 동시에 관리합니다. form에서 받은 데이터를 디코딩하면 바로 FormData를 얻고, 저장된 FormData를 다시 form 초기값으로 넣을 때는 인코딩하면 됩니다.
// form submit 처리
const handleSubmit = (rawFormData: Record<string, string>) => {
const result = Schema.decodeUnknownEither(ContactForm)(rawFormData)
if (result._tag === "Left") {
// 검증 실패 — 에러 메시지 표시
return
}
// result.right의 타입은 FormData
// age는 number, subscribe는 boolean, birthDate는 Date
saveContact(result.right)
}
// form 초기값 세팅
const setFormDefaults = (contact: FormData) => {
const defaults = Schema.encodeSync(ContactForm)(contact)
// defaults.age는 "25" (string), defaults.subscribe는 "on" (string)
// 바로 form input의 value로 사용 가능
}
검증 라이브러리의 역할이 "올바른 데이터인지 확인하는 것"에 머무르면, 입력과 결과의 타입이 같은 게 자연스럽습니다. 하지만 역할을 "외부 데이터와 내부 데이터 사이의 경계를 관리하는 것"으로 보면, 두 타입이 다를 수 있다는 것이 오히려 자연스럽습니다.
Zod 4.1의 z.codec()은 이 문제를 명시적으로 해결합니다. 양방향 변환이 필요한 곳에 codec을 정의하면 .decode()와 .encode()로 양방향 변환이 가능합니다. Effect Schema는 모든 스키마에 Type과 Encoded라는 두 개의 타입을 두고, 양방향 변환을 기본 설계로 가져갑니다. 자주 쓰이는 변환 스키마가 내장되어 있어서 별도 정의 없이 바로 사용할 수 있습니다.
단순한 입력 검증이 전부라면 어느 쪽이든 기본 스키마로 충분합니다. 하지만 외부 데이터와 내부 데이터의 형태가 다르고 양방향 변환이 필요하다면, Zod에서는 z.codec()을, Effect에서는 내장된 인코딩/디코딩 설계를 활용할 수 있습니다.