정의
거짓 타입 계약은 컴파일은 통과하지만 구현이 실제로 보장하지 않는 것을 약속하는 타입이에요.
타입 오류가 나는 코드는 금방 고쳐요. 문제는 반대예요. 타입은 조용한데 그 타입이 하는 약속을 런타임이 지키지 않는 경우죠. 이런 타입은 안전장치가 아니라 거짓 안전감을 줘요. 읽는 사람이 "여긴 확인됐구나" 하고 검사를 생략하게 만들거든요.
원칙은 한 줄이에요. 타입은 구현이 실제로 보장하는 범위까지만 약속해요.
왜 필요한가
구체적인 사고를 하나 볼게요.
function groupByStatus(orders: Order[]): Record<OrderStatus, Order[]> {
const result = {} as Record<OrderStatus, Order[]>;
for (const o of orders) {
(result[o.status] ??= []).push(o);
}
return result;
}
// 소비 지점
const grouped = groupByStatus(orders);
grouped.refunded.length; // 💥 환불 주문이 하나도 없으면 undefined
타입은 Record<OrderStatus, Order[]> 예요. 이건 "모든 OrderStatus 키가 결과에 존재한다"는 뜻이에요. 그런데 구현은 관찰된 상태만 채워요. 타입이 런타임보다 강하게 말한 거예요.
소비 지점에서는 잘못이 없어요. 타입이 있다고 했으니 있는 줄 알고 쓴 거니까요. 책임은 거짓 계약을 쓴 쪽에 있어요.
이런 계약이 위험한 건 오류가 정의 지점이 아니라 한참 떨어진 소비 지점에서 터지기 때문이에요. 그리고 그 소비 지점에는 아무 잘못도 없어요.
동작 원리
거짓 계약은 몇 가지 정형화된 모양으로 나타나요.
1. Record<K, V>는 totality 계약이에요
Record<K, V>는 "모든 K가 존재한다"는 약속이에요. 구현이 그걸 지킬 때만 써요.
| 구현이 하는 일 | 키 도메인 | 올바른 타입 |
|---|---|---|
| 모든 키를 사전 순회로 초기화 | 유한 union | Record<K, V> |
| 관찰된 키만 채움 (groupBy 등) | 유한 union | Partial<Record<K, V>> |
| 관찰된 키만 채움 | ID처럼 열린 도메인 | Map<K, V> |
열린 키 도메인에 Partial<Record<K, V>> 를 씌우는 건 Map이 이미 주는 V | undefined 조회 계약을 손으로 다시 만드는 것뿐이에요.
이건 Partial<DomainEntity> 를 mutation 페이로드로 쓰지 말라는 규칙과 다른 문제예요. 전자는 연산 의미를 잃는 patch고, 후자는 일부 키만 런타임에 존재한다는 결과 표현이에요. 둘을 같은 규칙으로 묶어 금지하면 오적용이에요.
2. type predicate는 검사 의무가 있어요
value is T 는 "이 함수가 통과시키면 진짜 T다"라는 약속이에요.
// ❌ 아무것도 검사하지 않는데 도메인 타입을 약속해요
function isUser(v: unknown): v is User {
return typeof v === "object" && v !== null;
}
// ❌ as를 감싸기만 한 predicate
function isUser(v: unknown): v is User {
return Boolean(v as User);
}
// ✅ 단순하고 정확한 narrowing만
function isNotNil<T>(v: T | null | undefined): v is T {
return v != null;
}
경계에서 들어오는 복잡한 도메인 타입은 predicate가 아니라 스키마 파서가 맡아요. predicate는 isNotNil 수준의 확실한 판정에만 남겨요.
3. wrapper의 반환 계약은 실행 시점을 따라요
호출 계약(Parameters)은 보존해도 되지만, ReturnType 보존은 wrapper가 같은 호출에서 실제로 값을 반환할 때만이에요.
type AnyFn = (...args: never[]) => unknown;
// debounce·schedule — 이번 호출에는 값이 없어요
type Deferred<F extends AnyFn> = (...args: Parameters<F>) => void;
// 캐시 — miss면 값이 없어요
type Cached<F extends AnyFn> = (...args: Parameters<F>) => ReturnType<F> | undefined;
// async wrapper
type Wrapped<F extends AnyFn> = (...args: Parameters<F>) => Promise<Awaited<ReturnType<F>>>;
debounce(fn) 이 ReturnType<typeof fn> 을 반환한다고 선언하면, 호출부는 즉시 값이 온다고 믿어요. 실제로는 undefined가 오고요.
4. excess property check는 sanitizer가 아니에요
이건 정말 자주 오해받아요.
type PublicUser = { id: string; name: string };
const source = { id: "1", name: "foo", passwordHash: "..." };
const user: PublicUser = source; // ✅ 컴파일 통과
JSON.stringify(user); // 💥 passwordHash가 그대로 나가요
초과 속성 검사는 객체 리터럴을 직접 대입할 때만 걸려요. 변수를 대입하면 걸리지 않고, 걸린다 해도 런타임에서 필드를 제거하지는 않아요. 타입은 컴파일 후 사라지니까요.
민감 필드 제거는 런타임 projection이나 파서가 소유해요.
// ✅ 실제로 제거해요
const user: PublicUser = { id: source.id, name: source.name };
5. key remapping 반환형은 런타임과 동형이어야 해요
함수가 실제로 키를 변환하지 않는데 ToCamelCaseKeys<T> 같은 반환 타입만 붙이면 거짓 계약이에요. 타입에서는 userName이 보이는데 런타임 객체에는 user_name이 들어 있어요.
실무 적용
자가 점검 질문
새 타입을 쓰기 전에 이걸 물어봐요.
이 타입이 하는 약속을 구현이 모든 경로에서 지키나요?
경로 하나라도 안 지키면 타입을 약하게 만들어요. 강한 타입 + 약한 구현보다, 약한 타입 + 명시적 검사가 항상 나아요.
// 약한 타입 — 소비 지점이 검사하게 강제해요
function groupByStatus(orders: Order[]): Partial<Record<OrderStatus, Order[]>>;
const grouped = groupByStatus(orders);
const refunded = grouped.refunded ?? []; // 검사가 강제돼요
satisfies·as const를 검증으로 보고하지 않기
satisfies, as const, 타입 annotation은 전부 컴파일 시점 도구예요. 런타임 데이터를 검증하거나 정제하지 않아요.
// ❌ 검증한 게 아니에요 — 그냥 컴파일러에게 우겨서 넘긴 거예요
const config = JSON.parse(raw) as AppConfig;
// ✅ 실제로 검사해요
const config = appConfigSchema.parse(JSON.parse(raw));
리뷰에서 잡는 법
코드 리뷰에서 이 계열을 찾는 가장 빠른 방법은 타입과 구현을 나란히 놓고 반례를 하나 찾아보는 것이에요. "이 함수가 빈 배열을 받으면 반환값이 저 타입을 만족하나요?" 한 문장이면 대부분 드러나요.
트레이드오프
약한 타입은 소비 지점에 부담을 줘요. Partial<Record<K, V>> 로 바꾸면 모든 호출부에 ?? [] 나 옵셔널 체이닝이 붙어요. 코드가 지저분해 보이고, "예전엔 안 그랬는데"라는 말이 나와요.
그런데 그 지저분함이 정확해요. 값이 없을 수 있다는 사실이 코드에 드러난 것뿐이거든요. 깔끔해 보였던 이전 버전은 없을 수 있다는 사실을 숨긴 거였어요.
반대 방향의 비용도 있어요. 강한 계약을 지키려고 구현을 고치는 선택지도 늘 있어요. groupBy 결과를 모든 키로 미리 초기화하면 Record<K, V> 가 진실이 돼요. 키가 유한하고 개수가 적으면 이쪽이 더 나은 경우가 많아요. 타입을 약하게 하는 것과 구현을 강하게 하는 것 중 무엇이 싼지 매번 따져 보세요.
사용하면 안 되는 경우
이 문서의 규칙을 과적용하면 안 되는 자리도 있어요.
- 모든
Record를Partial로 바꾸기. 구현이 실제로 모든 키를 채우면Record가 정확한 타입이에요. 불필요하게 약화하면 소비 지점에 의미 없는 null 체크만 늘어요. - 모든 predicate 금지.
isNotNil처럼 단순하고 정확한 narrowing은 predicate가 제일 읽기 좋아요. as const를 아예 쓰지 않기.as const는 리터럴 보존 도구지 검증 도구가 아니에요. 검증으로 보고하지 않으면 얼마든지 써도 돼요.
흔한 실수
{} as Record<K, V>로 시작해서 일부만 채워요. 이 문서의 대표 사례예요. 시작하는 단언이 계약을 거짓으로 만들어요.as로 API 응답을 도메인 타입으로 바꿔요. 파싱이 아니라 컴파일러 침묵이에요. 서버가 형식을 바꾸면 앱 깊은 곳에서 터져요.- 초과 속성 검사가 필드를 지운다고 믿어요. 타입은 런타임에 존재하지 않아요.
Partial<DomainEntity>를 mutation 페이로드로 써요.undefined가 "유지"인지 "삭제"인지 표현할 방법이 없어요.rename·clear-description같은 연산 union으로 나눠요.catch값을any처럼 다뤄요.useUnknownInCatchVariables가 꺼져 있으면 잡힌 오류가 검사 없이 흘러다녀요.@ts-ignore나 double assertion으로 오류를 지워요. 오류는 사라지지만 거짓 계약은 남아요.
관련 개념
- state-modeling-ladder — 상태 union이 실제로 필요한지부터 판단하는 순서
- type-level-testing — 계약이 실제로 무엇을 막는지 컴파일러 증거로 남기기
- typescript-variance — TypeScript가 의도적으로 불건전하게 남겨 둔 자리들
- typescript-environment-contract — 이 규칙 중 몇 개는 컴파일러 플래그가 켜져야 강제돼요