검증하지 말고 파싱하라 — TypeScript처럼 원하지 않는 언어에서
(cekrem.github.io)- 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할당이 실패해 컴파일러가 위치를 알려줌
- TypeScript의 구별된 유니언은 이 스타일에서 강력하지만, 전용
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가 여러 파일에 반복되면 검증해야 할 정보를 타입에 담지 못한 신호임
댓글과 토론
Lobste.rs 의견들
-
JavaScript/TypeScript가 원하는 코드 스타일과 기술적·인체공학적으로 충돌한다면, 수많은 JS로 컴파일되는 언어 중 하나를 쓰면 되지 않나 싶음
Haskell, Elm, F#이 언급되고, PureScript, js_of_ocaml, Reason, LunarML 등 글쓴이가 더 쓰고 싶어 하는 계열의 언어도 많음. 글쓴이는 Why TypeScript Won’t Save You라는 글까지 쓰며 선호 언어들과 더 비교했고 https://learnelm.dev도 운영함.
아니면 비교 자체가 목적이라서, TypeScript가 많은 경우 충분하지 않다는 걸 보여주고 다른 도구체인이나 아이디어 채택을 유도하려는 건가 싶기도 함- 기존 코드베이스, 팀의 특정 언어 숙련도나 회사 지침, 더 적은 지원·도구·커뮤니티 규모 같은 제약이 있음
대부분은 그냥 다른 언어를 고를 선택권이나 시간이 없음 - 보통은 큰 TypeScript 코드베이스가 있거나, 다른 언어에는 없는 TypeScript 라이브러리를 쓰기 때문일 것 같음
- 기존 코드베이스, 팀의 특정 언어 숙련도나 회사 지침, 더 적은 지원·도구·커뮤니티 규모 같은 제약이 있음
-
업무에서 브랜디드 타입(branded type) 을 아주 좋아하지만, 브랜디드 숫자로만 인덱싱 가능한 Array나 TypedArray를 만들 수 없다는 점이 정말 거슬림
TypedArray는 브랜디드 숫자를 저장하거나, 더 정확히는 꺼내 읽는 것조차 안 됨. IndexArray나 IndexTypedArray 같은 별도 타입 세트가 필요하더라도, 이런 기능이 꼭 있었으면 함- 나도 브랜디드 타입을 좋아하지만, 얘기해보면 다들 들이는 노력에 비해 별로라고 봄
꽤 복잡한 데이터베이스 스키마에서 모든 ID에 브랜디드 타입을 쓰면, 말이 안 되는 조인이나 조건을 만들 때 TypeScript가 잡아줌. 함수 시그니처도 더 명확해지고 여러 실수를 만들기 어려워짐 - 충분히 세게 거짓말할 의향이 있다면, 브랜디드 숫자로만 인덱싱 가능한 Array를 만들 수는 있음
원한다면 TypedArray의 값에도 같은 식으로 가능함 - 직장에서는 “스마트 enum”과 커스텀 배열 타입을 써서
TArray<Foo, MyEnum>처럼 작성할 수 있음. 다만 이건 C++ 얘기임
Zig의std라이브러리에는comptime으로 구현된 EnumArray가 있음. 조밀한 enum이나 희소한 enum을 인덱싱에 쓰고, 컴파일 시점에 올바른 인덱서를 계산하는 등 더 넓은 기능도 제공함.
이런 정밀한 타입 지정이 점점 마음에 듦. 코드베이스에 논리 버그가 들어오는 것 자체를 많이 막아줌
- 나도 브랜디드 타입을 좋아하지만, 얘기해보면 다들 들이는 노력에 비해 별로라고 봄