이펙티브 타입스크립트 2판 Day 10: 타입도 테스트하고.
Item 55–59 범위를 바탕으로 type test, 타입 표시, 꼬리 재귀 한계, 코드 생성, never exhaustiveness를 코드 리뷰에서 다루는 방법을 정리합니다.
범위: Effective TypeScript 2판 Item 55–59
오늘의 질문: “런타임 테스트가 통과했으면 타입 설계도 안전하다고 봐도 될까?”
type Result =
| { kind: "success"; value: string }
| { kind: "error"; message: string };
function render(result: Result) {
if (result.kind === "success") {
return result.value;
}
return "다시 시도해주세요";
}지금은 괜찮아 보입니다. 그런데 나중에 loading 상태가 추가되면 어떨까요?
type Result =
| { kind: "success"; value: string }
| { kind: "error"; message: string }
| { kind: "loading" };런타임 테스트가 success/error만 보고 있었다면 loading 누락을 놓칠 수 있습니다. 타입스크립트는 이런 변경을 알려줄 수 있지만, 코드가 그 신호를 받도록 작성되어 있어야 합니다.
런타임 테스트는 값의 동작을 확인합니다. 하지만 타입 설계의 의도는 값으로만 드러나지 않을 때가 있습니다.
예를 들어 public API 타입을 바꿀 때 이런 것을 확인하고 싶습니다.
특정 prop 조합은 허용되어야 한다. 서로 배타적인 prop 조합은 막혀야 한다. 반환 타입이 넓어지지 않아야 한다. 잘못된 호출은 계속 컴파일 에러여야 한다.
이때는 타입 테스트가 필요합니다.
type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
? true
: false;
type Expect<T extends true> = T;
type _UserIdIsString = Expect<Equal<User["id"], string>>;실무에서는 tsd, expect-type, vitest의 타입 테스트 패턴처럼 도구를 쓸 수 있습니다. 중요한 점은 도구 이름이 아니라 타입 관계가 깨졌을 때 PR에서 실패 신호가 나야 한다는 것입니다.
타입 테스트는 “이 타입이 맞다”뿐 아니라 “이 호출은 실패해야 한다”도 남깁니다.
createButton({ label: "저장", href: "/save" });
// @ts-expect-error href와 onClick은 동시에 받을 수 없다.
createButton({ label: "저장", href: "/save", onClick: () => {} });@ts-ignore와 달리 @ts-expect-error는 에러가 사라지면 오히려 실패합니다. 그래서 타입 설계가 느슨해졌을 때 알아차리기 쉽습니다.
리뷰에서는 이런 질문을 합니다.
이 타입 변경이 허용해야 할 예시와 막아야 할 예시를 둘 다 남겼나?
복잡한 조건부 타입이나 재귀 타입을 쓰면 IDE가 보여주는 타입이 길어질 수 있습니다. 타입 표시가 길다고 무조건 나쁜 것은 아니지만, 팀원이 에러 메시지를 읽고 수정 방향을 알 수 없다면 비용이 큽니다.
type DeepReadonly<T> = {
readonly [K in keyof T]: DeepReadonly<T[K]>;
};이런 유틸리티는 쓸모가 있지만, 깊은 객체·순환 구조·라이브러리 타입과 만나면 에러가 어려워질 수 있습니다. Item 55–59 구간의 감각은 “타입 시스템으로 어디까지 표현할까?”를 묻습니다.
타입이 구현보다 어렵다면 한 번 멈춰야 합니다.
public API에서 정말 필요한 제약인가? 더 단순한 union이나 명시적 overload로 충분하지 않은가? 타입 테스트로 의도를 설명할 수 있는가? 에러 메시지가 소비자에게 읽히는가?
discriminated union을 쓸 때는 마지막 분기가 중요합니다.
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${JSON.stringify(value)}`);
}
function render(result: Result) {
switch (result.kind) {
case "success":
return result.value;
case "error":
return result.message;
default:
return assertNever(result);
}
}Result에 loading이 추가되면 default의 result는 더 이상 never가 아닙니다. 컴파일러가 누락된 분기를 알려줍니다.
프론트엔드에서는 상태 모델이 계속 바뀝니다. idle, loading, success, empty, error, refreshing 같은 상태가 늘어납니다. never exhaustiveness는 “새 상태를 추가했는데 화면 분기를 안 고친” 실수를 줄여 줍니다.
API 스키마, GraphQL schema, OpenAPI, Prisma schema처럼 이미 권위 있는 출처가 있다면 타입을 손으로 맞추기보다 생성하는 편이 낫습니다.
OpenAPI schema → generated client types → frontend API wrapper좋은 코드 생성 흐름은 타입을 자동으로 만든다는 점보다, 타입의 출처가 하나로 정리된다는 점이 큽니다. 서버 계약이 바뀌면 생성 결과가 바뀌고, 프론트엔드가 그 변경을 컴파일 단계에서 봅니다.
다만 코드 생성도 만능은 아닙니다.
생성물이 너무 커서 리뷰가 어려우면 생성물과 작성 코드를 분리해야 한다. schema가 느슨하면 생성 타입도 느슨하다. 런타임 검증이 필요한 외부 입력은 여전히 검증해야 한다. 생성 스크립트가 CI에서 재현되지 않으면 로컬에서만 맞는 타입이 된다.
| 변화 | 확인할 질문 |
|---|---|
| public type 변경 | 허용/거부 예시가 타입 테스트로 남았나? |
@ts-expect-error 추가 | 실제로 의도한 실패를 고정하는가? |
| 조건부/재귀 타입 추가 | 에러 메시지와 표시 타입이 소비자에게 읽히는가? |
| union 상태 추가 | never exhaustiveness로 누락 분기를 잡는가? |
| API 타입 코드 생성 | schema→생성→사용 흐름이 CI에서 재현되는가? |
| 타입 설계가 런타임을 대신함 | 외부 입력 검증을 생략한 것은 아닌가? |
이번 범위의 핵심은 타입스크립트를 더 화려하게 쓰자는 말이 아닙니다. 타입 설계도 회귀할 수 있고, public API 타입도 깨질 수 있습니다. 그래서 타입은 문서이면서 테스트 대상이고, union은 상태 모델이면서 누락 분기를 막는 계약입니다.
Post Q&A
이펙티브 타입스크립트 2판 Day 10: 타입도 테스트하고 빠지는 분기는 막는다 전체를 기준으로 질문과 피드백을 받아요.답을 본 뒤에는 이 내용을 댓글로 달아서 서징에게도 물어볼 수 있어요. 작성자가 직접 볼 수 있어요!
Item 49–54 범위를 바탕으로 type coverage, 제네릭, 조건부 타입, 템플릿 리터럴 타입을 코드 리뷰에서 어떻게 판단할지 정리합니다.