정의
타입 경계값 분석은 테스트 설계의 경계값 분석(BVA, boundary value analysis) 을 타입 수준에 적용하는 방법이에요. 어떤 타입 테스트 케이스를 써야 하는지를 경계에서 고르는 거죠.
런타임 테스트에서는 이미 익숙한 얘기예요. "최소 2글자" 규칙이면 1글자·2글자·3글자를 확인하잖아요. 결함은 구간 한가운데가 아니라 경계에 몰려 있으니까요.
타입도 똑같아요. 다만 타입의 경계는 값의 크기가 아니에요. 타입 격자(lattice)의 극단이에요. never와 unknown 사이, 리터럴과 넓어진 string 사이, 속성 생략과 명시적 undefined 사이 — 여기가 타입의 min−1·min·min+1이에요.
왜 필요한가
타입 테스트를 쓸 때 제일 먼저 부딪히는 질문은 "뭘 써야 하지?"예요. 그리고 여기서 가장 흔한 답이 개수로 정하는 것이에요. "부정 케이스 3개는 쓰자" 같은 거죠.
개수를 기준으로 두면 무슨 일이 생기냐면, 숫자를 채워요.
// @ts-expect-error 없는 컬럼
const a: SortKey = "ordreNo";
// @ts-expect-error 없는 컬럼 (오타만 다름)
const b: SortKey = "ordrNo";
// @ts-expect-error 없는 컬럼 (또)
const c: SortKey = "orderNumber";
3개를 채웠어요. 그런데 셋 다 같은 경계를 세 번 건드렸어요. 리터럴이 string으로 넓어지는지, 읽기 전용 배열을 받는지, 추론 권위가 어디인지는 하나도 안 봤고요. 개수는 채웠는데 검출력은 그대로예요.
경계로 고르면 이 문제가 사라져요. 한 경계에 witness 한 쌍이면 되고, 겹치는 케이스는 자연히 걸러져요. 그리고 무엇을 안 봤는지가 목록으로 남아요.
동작 원리
먼저: 타입이 덮는 축과 못 덮는 축
런타임 BVA는 보통 네 축으로 봐요. 타입으로 옮기기 전에 어디까지 옮겨지는지 먼저 정해야 해요.
| 런타임 BVA 축 | 대표 경계 | 타입이 덮나 |
|---|---|---|
| 값 경계 | min−1 / min / min+1, 빈 값, 형식 | 덮어요 — 단 크기가 아니라 타입 격자의 극단으로 |
| 상태 경계 | 각 전이 직후 (idle→pending 등) | 부분만 — "표현 가능한 조합"까지. 실제 전이 발생은 못 봐요 |
| 시간·순서 경계 | 응답 역전, 언마운트 후 도착 | 못 덮어요 |
| 부작용 횟수 경계 | 0회 / 정확히 1회 / 2회 이상 | 못 덮어요 |
아래 두 줄이 이 문서에서 제일 중요해요. 시간축과 부작용 횟수는 타입으로 증명되지 않아요. union을 아무리 정교하게 짜도 요청이 두 번 나가는 건 못 막아요. 이건 런타임 테스트가 소유하는 영역이고, 타입 테스트를 다 짰다고 이 축이 덮이지 않아요.
상태 경계가 "부분만"인 이유도 같아요. 타입은 { status: 'loading'; error: Error } 같은 불가능한 조합을 표현 불가능하게 만들 수 있지만, "실패 직후에 loading이 실제로 해제되는지"는 실행해 봐야 알아요.
타입 격자의 7축
값 경계를 타입으로 옮기면 이 축들이 나와요. 각 축은 경계 하나고, 경계마다 통과 witness 1개와 거절 witness 1개를 둬요.
축 1. union 멤버 — 경계는 "멤버 / 비멤버"
type SortKey = "orderNo" | "total";
const ok: SortKey = "total";
// @ts-expect-error 비멤버는 거절돼야 해요
const bad: SortKey = "userId";
축 2. literal widening — 경계는 "리터럴 / 넓어진 string"
값에서 타입을 파생하는 API라면 반드시 봐야 하는 축이에요. 넓어지면 위 축 1의 보호가 통째로 사라지거든요.
const cols = defineColumns([{ id: "orderNo" }, { id: "total" }]);
type Key = ColumnIdOf<typeof cols>;
const ok: Key = "total"; // 리터럴이 살아 있어야 통과해요
// @ts-expect-error 이미 string으로 넓어진 값은 못 들어와요
const bad: Key = "total" as string;
축 3. optional — 경계는 "속성 생략 / 명시적 undefined"
exactOptionalPropertyTypes 실효 설정에 따라 판정이 갈려요. 이 축을 쓰기 전에 설정을 먼저 확인해요.
type Options = { retry?: number };
const ok: Options = {}; // 생략은 허용
// @ts-expect-error exactOptionalPropertyTypes 아래서는 명시적 undefined가 거절돼요
const bad: Options = { retry: undefined };
축 4. readonly — 경계는 "읽기 전용 입력 수용 / modifier 소실"
입력을 변경하지 않는 API가 readonly T[]와 as const 튜플을 받는지, 그리고 mapped type이 readonly를 잃지 않는지 봐요.
const frozen = [{ id: "orderNo" }] as const;
const ok = defineColumns(frozen); // readonly tuple을 받아야 해요
type Mapped = { readonly [K in keyof Config]: Config[K] };
// @ts-expect-error modifier가 보존되면 대입이 막혀요
const bad: Mapped = mutableConfig;
축 5. never·any·unknown — 경계는 "격자의 바닥 / 구멍 / 천장"
분배 conditional이 계약일 때만 이 축을 써요. 아니면 만들지 않아요.
type Boxed<T> = [T] extends [string] ? "yes" : "no";
type Distributed<T> = T extends string ? "yes" : "no";
const a: Distributed<never> = "no" as never; // never는 분배되면 never가 돼요
const b: Boxed<never> = "yes"; // 박싱하면 분배되지 않아요
// @ts-expect-error any는 양쪽 가지를 다 만족해 union이 돼요
const c: Distributed<any> = "yes" as const;
축 6. tuple arity — 경계는 "빈 튜플 / 1개 / n개"
계약이 실제로 길이를 구분할 때만 써요. 가변 인자 관계를 보존하는 API가 여기 해당해요.
const one = defineRoutes(["/home"]);
const many = defineRoutes(["/home", "/settings"]);
// @ts-expect-error 빈 목록은 라우트가 없다는 뜻이라 거절해요
const none = defineRoutes([]);
축 7. 추론 권위 — 경계는 "추론에 참여 / 참여하지 않음"
NoInfer나 const 타입 파라미터를 쓴다면 어느 인자가 권위인지가 계약이에요.
function pick<T>(options: readonly T[], fallback: NoInfer<T>): T;
const ok = pick(["a", "b"], "a"); // options에서만 T를 추론해요
// @ts-expect-error fallback은 union을 넓히지 못해요
const bad = pick(["a", "b"], "z");
실무 적용
절차 세 단계
1단계 — 축을 고르고, 안 고른 축에 사유를 적어요.
이번 API가 실제로 닫는 축만 고르고, 나머지는 왜 해당 없는지 한 줄 남겨요. 이 목록이 곧 리뷰 대상이에요.
| 축 | 적용 | 사유 |
| --------------- | ---- | ------------------------------------------ |
| union 멤버 | O | 정렬 키를 닫힌 union으로 노출 |
| literal widening| O | 컬럼 배열에서 union을 파생 |
| 추론 권위 | O | const 타입 파라미터로 리터럴 보존 |
| optional | - | optional 속성 없음 |
| readonly | - | 입력을 변경하지 않지만 배열을 받지 않음 |
| never/any/unknown | - | 분배 conditional 아님 |
| tuple arity | - | 길이를 구분하는 계약 없음 |
닫지 않는 축에 witness를 채워 넣는 건 개수 채우기와 같아요. 모든 타입에 같은 체크리스트를 붙이지 않아요.
2단계 — 축마다 witness 한 쌍을 써요.
통과 1개, @ts-expect-error 1개. 그리고 @ts-expect-error 한 줄에는 오용 하나만 담아요. 여러 개를 담으면 무관한 오류 하나로 통과해 버려요.
// 축: literal widening
const ok: Key = "total";
// @ts-expect-error 넓어진 string은 거절 — 이 줄이 축 하나를 소유해요
const bad: Key = "total" as string;
여기에 정상 호출 witness 1개를 전체에 하나 둬요. 명시적 타입 인자 없이 컴파일되는지 보는 거예요. 이게 안 되면 부정 케이스를 다 통과해도 쓸 만한 API가 아니에요.
3단계 — mutation으로 살아 있는지 확인해요.
축마다 계약을 일부러 부숴 보고 그 축의 witness가 빨개지는지 봐요.
| 축 | 부수는 방법 | 기대 |
|---|---|---|
| literal widening | const 타입 파라미터에서 const 제거 | 그 축 witness가 RED |
| 추론 권위 | NoInfer<T> → T | 그 축 witness가 RED |
| union 멤버 | union을 string으로 확대 | 그 축 witness가 RED |
| optional | exactOptionalPropertyTypes 끄기 | 그 축 witness가 RED |
빨개지지 않는 축이 있으면 그 witness는 처음부터 아무것도 안 지키고 있었던 거예요.
런타임으로 넘길 것을 같이 적어요
타입 축을 다 채웠으면, 못 덮은 두 축을 명시적으로 넘겨요.
- 시간·순서: 요청 역전·언마운트 후 도착 → abort signal + 런타임 테스트
- 부작용 횟수: 중복 제출 시 요청 정확히 1회 → 런타임 테스트
이 줄이 없으면 "타입 테스트 다 짰다"가 "다 검증됐다"로 읽혀요. 그게 이 방법에서 제일 흔한 사고예요.
트레이드오프
축으로 고르면 케이스 수가 줄어드는 게 보통이에요. 개수 기준으로 3개를 쓰던 API가 축으로는 2개만 나오기도 해요. 처음엔 부실해 보이는데, 검출력은 오히려 올라가요. 겹치는 케이스가 빠지고 안 보던 경계가 들어오니까요.
반대로 축이 많은 API도 있어요. 7축이 다 걸리면 witness가 14개예요. 이건 타입이 복잡한 게 아니라 API 표면이 넓다는 신호로 읽는 게 맞아요. 계속 늘어나면 케이스를 추가할 게 아니라 API를 나눠요. (한 API의 @ts-expect-error가 30개를 넘으면 그 시점이에요.)
축 선택 자체가 판단이라 비용이 있어요. 체크리스트를 기계적으로 다 채우는 것보다 생각을 요구해요. 대신 그 판단이 문서로 남아서 리뷰가 가능해져요 — "이 축은 왜 안 봤나요?"라고 물을 수 있어요.
사용하면 안 되는 경우
- 로컬 상태·내부 헬퍼. 호출부가 몇 개고 같은 파일에 있으면 축을 따지는 비용이 얻는 것보다 커요. 타입 테스트 자체가 과해요.
- 런타임 검증을 대신하려는 시도. 외부 입력은 축이 아니라 파서가 봐요. 템플릿 리터럴 타입으로 URL을 검증했다고 보고하면 안 돼요.
- 모든 API에 7축을 다 채우기. 이게 이 방법을 가장 확실하게 망치는 길이에요. 축 목록은 고를 메뉴지 채울 폼이 아니에요.
- 환경을 모른 채 optional·readonly 축을 쓰기.
exactOptionalPropertyTypes가 꺼져 있으면 축 3의 거절 witness가 성립하지 않아요.
흔한 실수
- 같은 경계를 여러 번 건드려요. 오타 세 개는 축 하나예요. 세 케이스가 아니라 한 케이스로 세요.
- 닫지도 않는 축에 witness를 만들어요. 분배 conditional이 아닌데
never·any·unknown을 넣으면 유지보수 대상만 늘어요. @ts-expect-error한 줄에 오용을 여러 개 담아요. 무관한 오류 하나로 통과해서 그 축이 비어 있는 걸 못 알아채요.@ts-ignore를 써요. 오류가 사라져도 조용해서 축이 무너진 걸 못 잡아요. 항상@ts-expect-error예요.- 정상 호출 witness를 빼먹어요. 거절만 확인하면 "아무것도 통과하지 않는 API"도 만점을 받아요.
- mutation 확인을 안 해요. 축마다 부숴 보지 않으면 witness가 죽어 있어도 알 수 없어요.
- 시간축·부작용 횟수를 타입으로 덮었다고 보고해요. 이 두 축은 타입 테스트가 끝나도 그대로 남아 있어요.
관련 개념
- type-level-testing — witness를 실제로 실행 가능하게 만드는 도구(
.test-d.ts,@ts-expect-error, mutation) - test-oracle — "무엇이 정답인가"의 출처 문제. 타입 수준에서는 "무엇이 컴파일되면 안 되는가"가 그 오라클이에요
- typescript-environment-contract — optional·readonly 축의 판정이 갈리는 컴파일러 설정
- derive-types-from-values — literal widening 축이 가장 자주 필요해지는 자리