검증하지 말고 파싱하라 — TypeScript처럼 원하지 않는 언어에서

3 hours ago 4
  • 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가 여러 파일에 반복되면 검증해야 할 정보를 타입에 담지 못한 신호임
Read Entire Article