1P by GN⁺ | ★ favorite | 댓글 1개
  • TypeScript 코드에 if (user.email) 같은 확인이 흩어지면, 이미 확인한 사실이 타입에 남지 않아 호출 스택 뒤쪽에서 같은 조건을 계속 의심하게 됨
  • 파서는 원시 입력을 받아 더 좁은 타입이나 실패 정보를 돌려주며, EmailAddress처럼 검증된 사실을 프로그램 나머지 부분이 신뢰할 수 있게 만듦
  • 구조적 타입 시스템을 쓰는 TypeScript에서는 stringEmail이 자연스럽게 분리되지 않아, 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을 가진 타입은 같은 타입으로 취급됨
    • stringstring이고, Haskell의 newtype처럼 진짜로 다른 타입을 만드는 기능은 없음
  • 커뮤니티에서 쓰는 우회법은 브랜딩(branding) 또는 태깅임
    • 간단한 방식은 { readonly __brand: "Email" } 같은 문자열 리터럴 phantom 필드
    • 더 강한 방식은 모듈 밖으로 내보내지 않는 unique symbol을 브랜드 키로 사용함
  • 예시 타입은 type Email = string & { readonly [EmailBrand]: true }, type Age = number & { readonly [AgeBrand]: true } 형태임
  • 브랜드 필드는 런타임에 존재하지 않는 타입 수준 마커이며, Emailstring을 컴파일 타임에 다르게 취급하게 함
  • 브랜드는 한 방향으로만 작동함
    • Emailstring에 할당 가능함
    • 일반 stringEmail로 바로 들어올 수 없음

파서는 신뢰 경계에서만 단언을 허용함

  • parseEmail(raw: string): Parsed<Email>은 문자열에 @가 없으면 실패를 돌려주고, 통과하면 raw as Email로 브랜드 타입을 만듦
  • as Email 단언은 파서가 신뢰 경계이기 때문에 허용되는 예외임
    • 코드베이스 다른 곳에서 stringEmail로 단언하면 설계가 무너짐
    • 파서를 별도 모듈에 두고, 브랜드 단언이 그 밖에 나타나면 버그로 취급할 수 있음
  • 예시의 Parsed<T>{ kind: "ok"; value: T } | { kind: "err"; error: ParseError } 형태임
    • 실패는 예외로 숨어 있지 않고 타입 서명에 나타남
    • kind: "ok" | "err" 같은 문자열 구별자를 쓰면 이후 변형이 추가될 때 타입 좁히기가 더 정직하게 동작함
  • parseEmail 예시는 의도적으로 얇으며, 실제 이메일 파서는 trim, lowercase, 도메인 검증 등을 더 처리해야 함

원시 입력과 신뢰된 도메인 타입 분리

  • UnvalidatedUserValidUser를 분리하면 네트워크나 외부 입력에서 온 값과 도메인에서 신뢰할 수 있는 값을 명확히 나눌 수 있음
    • UnvalidatedUserid, email, ageunknown으로 둠
    • ValidUserUserId, 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 표현식은 없음
    • switchdefault에서 const _exhaustive: never = result 같은 패턴을 직접 써야 함
    • Parsed에 세 번째 변형이 추가되면 never 할당이 실패해 컴파일러가 위치를 알려줌
  • satisfies는 캐스트보다 공손한 escape hatch로 쓰일 수 있음
    • const x = { ... } satisfies Config는 타입을 검사하면서도 리터럴 타입을 불필요하게 넓히지 않음
  • JSON.parseany를 반환하므로 즉시 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을 인덱싱에 쓰고, 컴파일 시점에 올바른 인덱서를 계산하는 등 더 넓은 기능도 제공함.
      이런 정밀한 타입 지정이 점점 마음에 듦. 코드베이스에 논리 버그가 들어오는 것 자체를 많이 막아줌