이펙티브 타입스크립트 2판 Day 13: 타입은 런타임과.
Item 74–78 범위를 바탕으로 런타임 타입 재구성, DOM과 환경 모델, 단위 테스트 관계, 컴파일러 성능을 코드 리뷰 관점에서 정리합니다.
범위: 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 타입을 바꿀 때 이런 것을 확인하고 싶습니다.
이때는 타입 테스트가 필요합니다.
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 구간의 감각은 “타입 시스템으로 어디까지 표현할까?”를 묻습니다.
타입이 구현보다 어렵다면 한 번 멈춰야 합니다.
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
좋은 코드 생성 흐름은 타입을 자동으로 만든다는 점보다, 타입의 출처가 하나로 정리된다는 점이 큽니다. 서버 계약이 바뀌면 생성 결과가 바뀌고, 프론트엔드가 그 변경을 컴파일 단계에서 봅니다.
다만 코드 생성도 만능은 아닙니다.
| 변화 | 확인할 질문 |
|---|---|
| public type 변경 | 허용/거부 예시가 타입 테스트로 남았나? |
@ts-expect-error 추가 | 실제로 의도한 실패를 고정하는가? |
| 조건부/재귀 타입 추가 | 에러 메시지와 표시 타입이 소비자에게 읽히는가? |
| union 상태 추가 | never exhaustiveness로 누락 분기를 잡는가? |
| API 타입 코드 생성 | schema→생성→사용 흐름이 CI에서 재현되는가? |
| 타입 설계가 런타임을 대신함 |
이번 범위의 핵심은 타입스크립트를 더 화려하게 쓰자는 말이 아닙니다. 타입 설계도 회귀할 수 있고, public API 타입도 깨질 수 있습니다. 그래서 타입은 문서이면서 테스트 대상이고, union은 상태 모델이면서 누락 분기를 막는 계약입니다.
Post Q&A
이펙티브 타입스크립트 2판 Day 10: 타입도 테스트하고 빠지는 분기는 막는다 전체를 기준으로 질문과 피드백을 받아요.답을 본 뒤에는 이 내용을 댓글로 달아서 서징에게도 물어볼 수 있어요. 작성자가 직접 볼 수 있어요!
| 외부 입력 검증을 생략한 것은 아닌가? |
Item 79–83 범위를 바탕으로 JS 현대화, @ts-check와 JSDoc, allowJs, 모듈별 마이그레이션, noImplicitAny를 코드 리뷰 관점에서 정리합니다.