Your Type Guard Can Silently Drift from Your TypeScript Type 🔧
TypeScript 타입과 타입 가드가 조용히 어긋날 수 있습니다
TypeScript의 사용자 정의 타입 술어는 함수 본문이 타입의 모든 필드를 검사하는지 증명하지 않습니다. 글에서는 기존 타입과 가드 정의를 컴파일 시점에 연결하는 is-kit의 typedStruct를 소개하고, 타입 동기화와 런타임 추가 속성 검증은 별개의 문제라고 설명합니다.
- 주제
AI 요약
TypeScript의 사용자 정의 타입 술어는 개발자가 컴파일러에 건네는 약속입니다. value is User라고 선언하면 TypeScript는 함수가 true를 반환할 때 값이 User라고 취급하지만, 검사 코드가 실제로 타입의 모든 필드를 확인하는지 증명하지는 않습니다. 그래서 타입을 수정한 뒤 가드를 갱신하지 않아도 컴파일이 통과할 수 있습니다.
타입과 런타임 검사가 어긋나는 문제
글은 id와 name을 가진 User 타입으로 시작합니다. 이에 맞춰 두 필드가 문자열인지 검사하는 isUser를 작성하면 타입과 런타임 검사가 일치합니다. 나중에 role: "admin" | "member"를 타입에 추가해도 가드에 role 검사를 보태지 않으면, 빠진 검사는 컴파일러가 알려주지 않습니다. 심지어 항상 true를 반환하는 함수도 value is User라는 타입 술어를 선언하면 유효한 TypeScript 코드입니다.
문제는 타입을 바꾸는 동안 속성을 추가·삭제·이름 변경하거나 선택 속성으로 바꾸고, 타입을 다른 종류로 바꿀 때마다 관련 런타임 검사도 함께 고쳐야 한다는 점입니다. 한쪽을 놓쳐도 컴파일러가 오류를 내지 않을 수 있습니다.
`typedStruct`로 기존 타입에 가드 연결하기
작성자는 is-kit의 typedStruct<User>()를 사용해 기존 타입과 객체 가드의 필드 정의를 연결합니다. 예를 들어 id: isString, name: isString, age: optionalKey(isNumber)처럼 작성합니다. 이후 User에 role을 추가했는데 가드 정의에서 빠뜨리면 TypeScript가 필드 누락을 오류로 표시합니다. name이 문자열인데 가드에서 isNumber를 사용해도 타입 불일치 오류가 납니다.
이 접근법은 유지보수를 없애지 않습니다. 대신 타입을 수정하고 가드를 놓쳤을 때 그 누락을 컴파일 시점에 드러냅니다. 런타임 검사 코드를 타입에서 자동 생성하는 방식은 아닙니다. 실행할 검사는 여전히 직접 선언해야 합니다.
선택 속성과 null은 다른 조건입니다
nickname?: string | null에는 두 조건이 들어 있습니다. 속성이 아예 없을 수 있고, 속성이 존재할 때 값이 null일 수도 있습니다. 글에서는 optionalKey(nullable(isString))로 두 조건을 분리해 표현합니다. 속성이 없는 객체와 nickname: null, 문자열 닉네임은 통과하고, 숫자 값은 거부합니다.
중첩 객체도 기존 타입을 재사용해 구성할 수 있습니다. Account["profile"]에서 NonNullable을 적용해 프로필 가드를 만들고, 계정 가드 안에서 이를 nullable로 감쌉니다. 배열은 arrayOf(isString)으로 검사합니다. 이렇게 타입을 컴파일 시점 기준으로 재사용하면서 런타임에서는 작은 가드를 조합합니다.
타입 동기화와 추가 속성 정책은 별개입니다
기본 설정에서는 typedStruct가 선언된 필드를 검사하며, 객체에 정의되지 않은 추가 필드가 있어도 허용합니다. 예시의 exact: true를 켜면 debug처럼 가드에 선언되지 않은 자체 열거형 문자열 키가 있는 객체를 거부합니다. 글은 가드 정의가 TypeScript 타입과 맞는지 확인하는 문제와, 런타임 객체의 추가 속성을 허용할지 정하는 문제를 구분합니다.
검증 방식은 데이터 모양을 누가 관리하는지에 따라 고릅니다. 가드가 타입을 결정한다면 가드에서 타입을 추론하는 방식이 적합할 수 있고, 이미 존재하는 TypeScript 타입이 기준이라면 typedStruct처럼 가드를 그 타입에 연결할 수 있습니다. 변환, 강제 변환, 기본값, 자세한 검증 오류가 필요하다면 스키마 라이브러리나 코드 생성이 더 적합할 수 있습니다. typedStruct는 사용자 정의 술어가 올바른지 증명하거나 값을 변환하지 않으며, 상세 오류를 반환하지도 않습니다. 글의 요점은 타입 술어가 증명이 아니라 약속이라는 점입니다.
dev.to 반응
- @kyisaiah47 — 다음으로 추가 키도 테스트해 보겠습니다.
typedStruct<User>()는 선언된 맵에 없는 필드가 포함된 런타임 객체를 거부하나요, 아니면 알고 있는 필드만 검사하나요?- @nyaomaru — 댓글 감사합니다! 기본적으로
typedStruct<User>()는 알고 있는 필드를 검사하므로 추가 런타임 필드는 허용합니다. 추가 자체 열거형 문자열 키도 거부하려면exact: true를 켜면 됩니다. 다만typedStruct의 주된 목적은 스키마 검증기와 조금 다릅니다. 기존 TypeScript 타입과 동기화된 타입 안전한 가드를 작성하도록 돕습니다. 컴파일 시점의 가드 모양과 런타임의 정확성은 별개로 선택할 수 있습니다.
- @nyaomaru — 댓글 감사합니다! 기본적으로
- @sinarezaei — TypeScript 정적 타입과 런타임 검증 사이의 간극을 잘 보여주는 예시입니다. 타입 술어는 구현이 타입 전체를 실제로 검사하지 않아도 컴파일러에 완전히 유효한 코드로 인정받는다는 점이 눈에 띕니다.
User타입을 업데이트해도 이전 계약만 검사하는 가드가 남을 수 있다는 점에서 조용한 어긋남이 위험합니다.typedStruct<User>()는 관계를 명시하고 타입과 가드가 어긋났을 때 컴파일러가 확인할 구체적인 기준을 제공합니다. 런타임 검사를 관리할 필요까지 없애지는 않지만, 갱신을 잊고 지나치기 어렵게 만듭니다. 그게 실제 장점이라고 봅니다. - @naveen_alavilli — 핵심은
value is User가 컴파일러가 받아들이지만 검사할 수 없는 약속이라는 점입니다. 어느 방향의 어긋남이 더 해로운지도 덧붙일 만합니다. 오래된 가드가false를 너무 자주 반환하면 거부가 발생하고, 버그 신고가 들어오며, 그날 고칠 수 있습니다. 반대로 오래된 가드가true를 너무 자주 반환하는 경우가 글의role예시입니다. 잘못된 객체가 경계를 통과한 뒤 검증을 담당하지도 않은 함수 안에서 훨씬 나중에 실패합니다. 문제는 버그 자체보다 증상과 원인 사이의 거리입니다. 그래서 저는 검증으로 어긋남을 찾아내기보다 의존 방향을 뒤집습니다. 검증기에서 타입을 추론하면userSchema에role을 추가할 때 타입도 자동으로 바뀌어, 잊어버려서 생기는 어긋남을 표현할 수 없는 문제가 됩니다. 가드와 타입을 함께 검사하는 테스트도 좋지만, 다른 설계라면 애초에 막았을 실수를 잡는 방식입니다. 손으로 가드를 작성할 곳은 런타임 모양과 타입이 실제로 다른 경우라고 봅니다. 예를 들어 레거시 API에서 서로 다른 세 필드가 같은 뜻을 나타내는 경우입니다.
원문: dev.to / 번역·요약: Trawling