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

> Clean Markdown view of GeekNews topic #31013. Use the original source for factual precision when an external source URL is present.

## Metadata

- GeekNews HTML: [https://news.hada.io/topic?id=31013](https://news.hada.io/topic?id=31013)
- GeekNews Markdown: [https://news.hada.io/topic/31013.md](https://news.hada.io/topic/31013.md)
- Type: GN+
- Author: [neo](https://news.hada.io/@neo)
- Published: 2026-07-02T02:04:15+09:00
- Updated: 2026-07-02T02:04:15+09:00
- Original source: [cekrem.github.io](https://cekrem.github.io/posts/parse-dont-validate-typescript/)
- Points: 1
- Comments: 1

## Topic Body

- TypeScript 코드에 `if (user.email)` 같은 확인이 흩어지면, 이미 확인한 사실이 타입에 남지 않아 호출 스택 뒤쪽에서 같은 조건을 계속 의심하게 됨
- 파서는 원시 입력을 받아 **더 좁은 타입**이나 실패 정보를 돌려주며, `EmailAddress`처럼 검증된 사실을 프로그램 나머지 부분이 신뢰할 수 있게 만듦
- 구조적 타입 시스템을 쓰는 TypeScript에서는 `string`과 `Email`이 자연스럽게 분리되지 않아, `unique symbol` 기반 **브랜디드 타입**과 제한된 `as` 단언으로 명목적 경계를 흉내 냄
- `Parsed&lt;T&gt;` 같은 **구별된 유니언**은 성공과 실패를 타입 서명에 드러내지만, 전용 `match` 표현식이 없어 `never`를 이용한 exhaustive check를 직접 작성해야 함
- Zod, io-ts, valibot은 스키마에서 파서와 TypeScript 타입을 함께 만들 수 있지만, 외부 입력을 도메인 타입으로 보기 전 **경계마다 파싱**하는 규율은 여전히 개발자에게 남아 있음

---

### 검증은 정보를 버리고, 파싱은 타입에 남김
- [Alexis King의 Parse, don’t validate](https://lexi-lambda.github.io/blog/2019/11/05/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&lt;Email&gt;`은 문자열에 `@`가 없으면 실패를 돌려주고, 통과하면 `raw as Email`로 브랜드 타입을 만듦
- `as Email` 단언은 파서가 신뢰 경계이기 때문에 허용되는 예외임
  - 코드베이스 다른 곳에서 `string`을 `Email`로 단언하면 설계가 무너짐
  - 파서를 별도 모듈에 두고, 브랜드 단언이 그 밖에 나타나면 버그로 취급할 수 있음
- 예시의 `Parsed&lt;T&gt;`는 `{ 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&lt;ValidUser&gt;`는 원시 입력을 단계적으로 좁힘
  - 입력이 객체인지 확인함
  - `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&lt;typeof ValidUserSchema&gt;`로 타입을 얻음
  - `ValidUserSchema.safeParse(rawInput)`은 성공 시 data, 실패 시 error를 돌려줌
- Zod의 `.brand()`도 손으로 만든 symbol 브랜드처럼 **타입 수준 기능**이며 런타임 동작은 없음
- 라이브러리는 파서와 타입을 같은 정의에 묶어 경계를 더 쉽게 지키게 하지만, 모든 외부 경계에서 이를 사용해야 한다는 규율을 대신 강제하지는 않음
- 네트워크에서 온 `User`는 파싱되기 전까지 도메인 `User`가 아니며, 타입 단언으로 오류 메시지를 우회하려는 유혹을 피해야 함

### 증거를 기억이 아니라 타입에 싣기
- 작은 원칙은 “타입 시스템이 증거를 들고 있게 하고, 사람의 기억에 맡기지 말라”는 것임
- 어떤 조건을 확인하고 그 결과를 타입에 인코딩하지 않으면, 이후 코드는 그 검증이 이미 끝났다고 쉽게 가정함
- TypeScript에서는 이 원칙이 세 가지 도구에 기대어 구현됨
  - 명목적 정체성을 흉내 내는 **브랜디드 타입**
  - 성공과 실패를 드러내는 **구별된 유니언**
  - 외부 입력의 `unknown`과 신뢰된 도메인 타입 사이의 엄격한 경계
- 모든 코드를 파싱 파이프라인으로 바꾸는 것이 항상 적절한 것은 아니지만, 같은 방어적 `if`가 여러 파일에 반복되면 검증해야 할 정보를 타입에 담지 못한 신호임

## Comments



### Comment 60985

- Author: neo
- Created: 2026-07-02T02:04:16+09:00
- Points: 1

###### [Lobste.rs 의견들](https://lobste.rs/s/lzewut/parse_don_t_validate_language_doesn_t_want) 
- 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를 [만들 수는 있음](https://www.typescriptlang.org/play/?#code/C4TwDgpgBAQgTgQwHYBMIoHIFcC2AjCOKAXigAMB9CgZwgGM4Jgq9FUqASAbyVwLgC+ZANwAoUaEhQAgnEQgACnAD2wVeAgBZJgAtlKagB4AKgD4SULqKhQA2gGkoASyRQA1hBDKAZjLkIQE3MEaj95IIcAXSgIAA9gCFRQgDEsJDpgJ2VXAH4oRwAuKCQIADdCSKLZcLMo0QExSWgAeSQAGxB4ZDQUaTo6CGpqaoCLACV6ZTgUQy7UdGx8QgAaKGpgOBcAc3MAMjCApVV1SG1gPQNDdc2kHbFxbzSMrNctpjmexf4ACgQ4LaKvCWcAAlJZrDYoAB6KFQUoINpOFAITLZKA6QgQAB0OIhNkYwCwcFcfy2UBCUDSbiQygA7iTQh8FnxCGIBA8nqjXAwICiIEzev4QN8wVZIVACUTXLZohSqTT6eTQq0OgK+gMhiMQGzxHRsutyXILDy+WqhSL7n84LY3sABV9CN8AEwg6KkADk3mUyndwmhsNpUzcoW8Lgg4itWJwCDA31iJHM8YA1FB3bSdHzynB3SC-TCoIG4MGoKGSqs8FhgBKmFLQghitkALSsbroLURuS2J1u1Nen152GEFRwURAA)  
    원한다면 TypedArray의 값에도 [같은 식으로 가능함](https://www.typescriptlang.org/play/?#code/C4TwDgpgBAQgTgQwHYBMIoHIFcC2AjCOKAXigAMB9CgZwgGM4Jgq9FUqASAbyVwLgC+ZANwAoUaEiw2aFAFUAlkmAAOAIJxEIElABK9APZwUAHl75CAGmnJZ2C3AB8Y0QDMsSOsAUGkUAOZM8Lbo9vwAFAhw-gBcUOb8AJRQXKJQ6VAA9JlQAG4IADYKKAjevlAAFoQQAHR1aRmMwFhwflH+UAjUUB4A1kgGAO5t3cGooXyEYgLi7p5lfgwQpRBjsgAqauEFEEj+wBVxCYTJqRlQTS1+SBCDUIrK6poIINu7+xXJXT1I-UMjNnG8iUqg0Wmm4jovmowE6mh0SxWa3QmCABZEi4onAANoARgAujpAsBkZhJnB0QAmTFZHKDIy9bquJQQSHQ2GuAwGOJksKEHTY-EE4R0qAMuBMqAsm7iIVUomkADMouyUEIcCMoihSBhUDwUSOFMFmmFqpyGqMQA)
  - 직장에서는 “스마트 enum”과 커스텀 배열 타입을 써서 `TArray<Foo, MyEnum>`처럼 작성할 수 있음. 다만 이건 **C++** 얘기임  
    Zig의 `std` 라이브러리에는 `comptime`으로 구현된 EnumArray가 있음. 조밀한 enum이나 희소한 enum을 인덱싱에 쓰고, 컴파일 시점에 올바른 인덱서를 계산하는 등 더 넓은 기능도 제공함.  
    이런 **정밀한 타입 지정**이 점점 마음에 듦. 코드베이스에 논리 버그가 들어오는 것 자체를 많이 막아줌
