검증하지 말고 파싱하라 — TypeScript처럼 원하지 않는 언어에서
4 hours ago
6
- TypeScript 코드에 if (user.email) 같은 확인이 흩어지면, 이미 확인한 사실이 타입에 남지 않아 호출 스택 뒤쪽에서 같은 조건을 계속 의심하게 됨
- 파서는 원시 입력을 받아 더 좁은 타입이나 실패 정보를 돌려주며, EmailAddress처럼 검증된 사실을 프로그램 나머지 부분이 신뢰할 수 있게 만듦
- 구조적 타입 시스템을 쓰는 TypeScript에서는 string과 Email이 자연스럽게 분리되지 않아, unique symbol 기반 브랜디드 타입과 제한된 as 단언으로 명목적 경계를 흉내 냄
- Parsed<T> 같은 구별된 유니언은 성공과 실패를 타입 서명에 드러내지만, 전용 match 표현식이 없어 never를 이용한 exhaustive check를 직접 작성해야 함
- Zod, io-ts, valibot은 스키마에서 파서와 TypeScript 타입을 함께 만들 수 있지만, 외부 입력을 도메인 타입으로 보기 전 경계마다 파싱하는 규율은 여전히 개발자에게 남아 있음
검증은 정보를 버리고, 파싱은 타입에 남김
- Alexis King의 Parse, don’t validate 원칙은 검증기와 파서의 차이를 중심에 둠
- 검증기는 “이 값은 괜찮다”고 판단한 뒤 boolean이나 예외로 흐름을 넘김
- 파서는 원시 입력을 받아 더 정밀한 타입을 만들거나 실패 이유를 돌려줌
- User.email: string, User.age: number처럼 타입이 넓게 남아 있으면 isValidUser(user): boolean이 통과해도 TypeScript는 그 사실을 기억하지 못함
- 이후 emailService.send(user.email, ...) 같은 코드에서 user.email은 여전히 빈 문자열, "hello", "definitely not an email" 같은 일반 string임
- 같은 조건을 여러 곳에서 다시 확인하는 흐름은 King이 말한 shotgun parsing에 가까움
타입 자체가 증거가 되는 API
- 원하는 형태는 sendWelcome(user: ValidUser)처럼 파싱된 값만 받을 수 있는 함수 시그니처임
- 이 구조에서는 sendWelcome을 호출하기 전에 반드시 파서를 통과해야 하며, 함수 내부에서 별도 재검증이나 방어적 if가 필요하지 않음
- Elm에서는 opaque type과 smart constructor로 간단히 처리할 수 있지만, TypeScript에서는 같은 효과를 내려면 더 많은 장치가 필요함
브랜디드 타입으로 명목적 경계 만들기
- TypeScript는 구조적 타입 시스템을 사용하므로 같은 shape을 가진 타입은 같은 타입으로 취급됨
- string은 string이고, Haskell의 newtype처럼 진짜로 다른 타입을 만드는 기능은 없음
- 커뮤니티에서 쓰는 우회법은 브랜딩(branding) 또는 태깅임
- 간단한 방식은 { readonly __brand: "Email" } 같은 문자열 리터럴 phantom 필드
- 더 강한 방식은 모듈 밖으로 내보내지 않는 unique symbol을 브랜드 키로 사용함
- 예시 타입은 type Email = string & { readonly [EmailBrand]: true }, type Age = number & { readonly [AgeBrand]: true } 형태임
- 브랜드 필드는 런타임에 존재하지 않는 타입 수준 마커이며, Email과 string을 컴파일 타임에 다르게 취급하게 함
- 브랜드는 한 방향으로만 작동함
- Email은 string에 할당 가능함
- 일반 string은 Email로 바로 들어올 수 없음
파서는 신뢰 경계에서만 단언을 허용함
- parseEmail(raw: string): Parsed<Email>은 문자열에 @가 없으면 실패를 돌려주고, 통과하면 raw as Email로 브랜드 타입을 만듦
- as Email 단언은 파서가 신뢰 경계이기 때문에 허용되는 예외임
- 코드베이스 다른 곳에서 string을 Email로 단언하면 설계가 무너짐
- 파서를 별도 모듈에 두고, 브랜드 단언이 그 밖에 나타나면 버그로 취급할 수 있음
- 예시의 Parsed<T>는 { kind: "ok"; value: T } | { kind: "err"; error: ParseError } 형태임
- 실패는 예외로 숨어 있지 않고 타입 서명에 나타남
- kind: "ok" | "err" 같은 문자열 구별자를 쓰면 이후 변형이 추가될 때 타입 좁히기가 더 정직하게 동작함
- parseEmail 예시는 의도적으로 얇으며, 실제 이메일 파서는 trim, lowercase, 도메인 검증 등을 더 처리해야 함
원시 입력과 신뢰된 도메인 타입 분리
- UnvalidatedUser와 ValidUser를 분리하면 네트워크나 외부 입력에서 온 값과 도메인에서 신뢰할 수 있는 값을 명확히 나눌 수 있음
- UnvalidatedUser는 id, email, age를 unknown으로 둠
- ValidUser는 UserId, Email, Age 같은 브랜드 타입을 사용함
- UserId도 브랜드 처리하면 UserId가 필요한 곳에 OrderId 같은 다른 ID를 잘못 넘기는 실수를 막을 수 있음
- parseUser(raw: unknown): Parsed<ValidUser>는 원시 입력을 단계적으로 좁힘
- 입력이 객체인지 확인함
- id, email, age 필드 존재 여부를 확인함
- email이 문자열인지 확인함
- parseUserId, parseEmail, parseAge를 각각 호출하고 실패 시 즉시 반환함
- 모두 성공하면 ValidUser를 반환함
- 이 방식은 F#이나 Elm보다 장황하지만, sendWelcome(user: ValidUser)가 실제로 안전해짐
TypeScript가 거슬리는 지점들
- 첫 번째 마찰은 파서 내부의 as Email 단언임
- 진짜 명목 타입 언어에서는 smart constructor가 거짓말 없이 새 타입을 반환할 수 있음
- TypeScript의 브랜드는 가상의 타입 마커이므로 파서가 단언으로 넘어가야 함
- 두 번째 마찰은 exhaustive check임
- TypeScript의 구별된 유니언은 이 스타일에서 강력하지만, 전용 match 표현식은 없음
- switch의 default에서 const _exhaustive: never = result 같은 패턴을 직접 써야 함
- Parsed에 세 번째 변형이 추가되면 never 할당이 실패해 컴파일러가 위치를 알려줌
- satisfies는 캐스트보다 공손한 escape hatch로 쓰일 수 있음
- const x = { ... } satisfies Config는 타입을 검사하면서도 리터럴 타입을 불필요하게 넓히지 않음
- JSON.parse는 any를 반환하므로 즉시 unknown으로 주석 처리하는 편이 안전함
- const raw: unknown = JSON.parse(input) 형태로 받고, 이후 파서가 도메인 타입 여부를 판단함
- JSON.parse는 검증기가 아니라 바이트를 JS 값으로 바꾸는 역직렬화 단계임
Zod와 같은 라이브러리가 줄이는 반복
- Zod, io-ts, valibot은 손으로 작성한 파서보다 더 편한 방식으로 같은 패턴을 제공함
- Zod 예시는 하나의 스키마에서 파서와 TypeScript 타입을 함께 만듦
- z.object({ id: z.number().int(), email: z.string().email().brand<"Email">(), age: z.number().int().min(0).max(150).brand<"Age">() })
- z.infer<typeof ValidUserSchema>로 타입을 얻음
- ValidUserSchema.safeParse(rawInput)은 성공 시 data, 실패 시 error를 돌려줌
- Zod의 .brand()도 손으로 만든 symbol 브랜드처럼 타입 수준 기능이며 런타임 동작은 없음
- 라이브러리는 파서와 타입을 같은 정의에 묶어 경계를 더 쉽게 지키게 하지만, 모든 외부 경계에서 이를 사용해야 한다는 규율을 대신 강제하지는 않음
- 네트워크에서 온 User는 파싱되기 전까지 도메인 User가 아니며, 타입 단언으로 오류 메시지를 우회하려는 유혹을 피해야 함
증거를 기억이 아니라 타입에 싣기
- 작은 원칙은 “타입 시스템이 증거를 들고 있게 하고, 사람의 기억에 맡기지 말라”는 것임
- 어떤 조건을 확인하고 그 결과를 타입에 인코딩하지 않으면, 이후 코드는 그 검증이 이미 끝났다고 쉽게 가정함
- TypeScript에서는 이 원칙이 세 가지 도구에 기대어 구현됨
- 명목적 정체성을 흉내 내는 브랜디드 타입
- 성공과 실패를 드러내는 구별된 유니언
- 외부 입력의 unknown과 신뢰된 도메인 타입 사이의 엄격한 경계
- 모든 코드를 파싱 파이프라인으로 바꾸는 것이 항상 적절한 것은 아니지만, 같은 방어적 if가 여러 파일에 반복되면 검증해야 할 정보를 타입에 담지 못한 신호임
-
Homepage
-
Tech blog
- 검증하지 말고 파싱하라 — TypeScript처럼 원하지 않는 언어에서