이펙티브 타입스크립트 2판 Day 13: 타입은 런타임과.
Item 74–78 범위를 바탕으로 런타임 타입 재구성, DOM과 환경 모델, 단위 테스트 관계, 컴파일러 성능을 코드 리뷰 관점에서 정리합니다.
범위: Effective TypeScript 2판 Item 42–48
오늘의 질문: “API 응답 타입을 모를 때any로 빨리 넘기는 게 정말 더 빠를까?”
async function loadUser(id: string) {
const response = await fetch(`/api/users/${id}`);
const data: any = await response.json();
return {
id: data.id,
name: data.profile.name,
isAdmin: data.role === "admin",
};
}
이 코드는 타입 에러가 거의 나지 않습니다. 그래서 안전해 보이기도 합니다. 하지만 실제로는 타입스크립트가 “검사할 근거를 잃은” 상태에 가깝습니다. data.profile이 없거나 id가 숫자여도 내부 코드까지 any가 번지면 리뷰 포인트가 사라집니다.
Item 42–48 구간의 핵심은 타입 시스템을 이기는 요령이 아니라, 검사가 필요한 경계와 검사가 끝난 내부를 분리하는 습관입니다. any, unknown, 타입 단언, monkey patching은 모두 “타입스크립트가 모르는 값”을 다루는 방법입니다. 차이는 그 모르는 상태를 내부 코드로 흘려보내느냐, 경계에서 멈추게 하느냐에 있습니다.
any는 가끔 필요한 탈출구입니다. 문제는 탈출구가 통로가 될 때입니다. 외부 입력, 서드파티 라이브러리, 오래된 JS 모듈에서 들어온 값을 any로 받아 내부 타입까지 오염시키면, 타입스크립트가 해주던 질문이 사라집니다.
function renderUser(user: any) {
return user.profile.displayName.toUpperCase();
}
이 함수는 모든 호출을 받아들입니다. 리뷰할 때는 “타입이 맞나?”를 볼 수 없고, 런타임에서 터진 뒤에야 경계가 없었다는 사실을 알게 됩니다.
반대로 unknown은 불편합니다. 그 불편함이 장점입니다.
type UserPayload = {
id: string;
profile: { displayName: string };
};
function isUserPayload(value: unknown): value is UserPayload {
if (typeof value !== "object" || value === null) return false;
const record = value as Record<string, unknown>;
const profile = record.profile;
return (
typeof record.id === "string" &&
typeof profile === "object" &&
profile !== null &&
typeof (profile as Record<string, unknown>).displayName === "string"
);
}
unknown은 바로 읽을 수 없기 때문에, 검증 함수나 스키마를 경계에 세우도록 압박합니다. 내부로 들어온 뒤에는 UserPayload로 다룰 수 있으니 컴포넌트와 서비스 함수가 덜 방어적으로 됩니다.
현실적인 코드베이스에서 any를 0으로 만들기는 어렵습니다. 문제는 any의 존재가 아니라 범위입니다.
async function loadUserSafe(id: string): Promise<UserPayload> {
const response = await fetch(`/api/users/${id}`);
const data: unknown = await response.json();
if (!isUserPayload(data)) {
throw new Error("Invalid user payload");
}
return data;
}
이렇게 쓰면 위험한 부분이 한 줄로 사라지는 것은 아닙니다. 대신 위험이 어디 있는지 보입니다. 코드 리뷰에서는 loadUserSafe 내부의 검증을 집중해서 보면 되고, 그 아래 컴포넌트는 UserPayload를 믿고 읽을 수 있습니다.
이 관점에서 any 사용을 세 등급으로 나눠 볼 수 있습니다.
| 위치 | 판단 | 리뷰 질문 |
|---|---|---|
| 외부 입력을 처음 받는 아주 좁은 경계 | 제한적으로 허용 가능 | 곧바로 unknown/검증/변환으로 닫히는가? |
| 내부 도메인 모델, props, state | 위험 | 호출자가 잘못 넘겨도 타입스크립트가 침묵하지 않는가? |
| 공용 유틸의 반환 타입 | 매우 위험 | 한 번의 any가 여러 호출부로 퍼지지 않는가? |
“any 금지”라는 규칙만으로는 부족합니다. 더 좋은 규칙은 “any가 들어오는 지점과 멈추는 지점을 PR에서 설명할 수 있어야 한다”입니다.
const data = (await response.json()) as UserPayload;
이 코드는 짧습니다. 하지만 as UserPayload는 런타임 값을 바꾸지 않습니다. 타입스크립트에게 “내가 맞다고 치자”라고 말하는 것에 가깝습니다. 호출자가 정말 그 형태를 보장한다면 괜찮지만, 외부 API 응답처럼 변할 수 있는 경계에서는 단언만으로 충분하지 않습니다.
코드 리뷰에서는 단언을 보면 이렇게 나눠 봅니다.
| 단언 위치 | 리뷰 판단 |
|---|---|
| 테스트 픽스처 | 의도적으로 좁힌 데이터면 허용 가능 |
| DOM API처럼 TS가 모르는 곳 | null 체크나 환경 조건이 옆에 있으면 허용 가능 |
| 외부 JSON 응답 | 단언만 있으면 위험, 검증 함수/스키마 경계가 필요 |
| 내부 도메인 변환 후 | 변환 함수가 검증을 끝냈다면 단언 없이 타입이 좁아져야 함 |
특히 as SomeType이 함수 깊은 곳에 반복해서 나오면, 보통은 타입 모델이나 경계 함수가 빠졌다는 신호입니다.
function getPrimaryEmail(user: unknown) {
return (user as UserPayload).profile.displayName;
}
이 코드는 unknown을 받는 척하지만 사실상 내부에서 any처럼 사용합니다. unknown을 쓴 이유가 검증 강제라면, 단언 하나로 그 압박을 제거하지 않아야 합니다.
직접 type guard를 쓰는 방법만 있는 것은 아닙니다. 중요한 것은 “값을 읽기 전에 근거를 만든다”입니다.
type ApiResult =
| { ok: true; user: UserPayload }
| { ok: false; reason: "invalid_payload" | "network_error" };
async function fetchUser(id: string): Promise<ApiResult> {
try {
const response = await fetch(`/api/users/${id}`);
const data: unknown = await response.json();
if (!isUserPayload(data)) {
return { ok: false, reason: "invalid_payload" };
}
return { ok: true, user: data };
} catch {
return { ok: false, reason: "network_error" };
}
}
여기서 포인트는 에러 처리까지 타입으로 드러난다는 점입니다. 호출부는 user.profile.displayName을 바로 읽기 전에 ok 상태를 확인해야 합니다.
const result = await fetchUser(userId);
if (!result.ok) {
showToast(result.reason);
return;
}
renderName(result.user.profile.displayName);
외부 입력 경계에서 검증하고, 실패 상태를 모델링하고, 내부 UI는 좁혀진 타입만 받게 만드는 흐름입니다. Day 6에서 다룬 유효 상태 모델링과도 이어집니다.
Item 42–48 구간을 읽을 때 any 개수를 세는 도구만 떠올리기 쉽습니다. 타입 커버리지 수치는 도움이 됩니다. 하지만 숫자가 높아도 중요한 경계에 any가 남아 있으면 위험은 큽니다.
예를 들어 다음 두 PR을 비교해 봅니다.
any가 3개 있다.any다.단순 개수로는 1번이 더 나빠 보일 수 있습니다. 코드 리뷰에서는 보통 2번이 더 위험합니다. any가 사용자의 돈, 인증, 저장 데이터, 라우팅, 공개 API 타입으로 가까워질수록 우선순위가 올라갑니다.
그래서 no-explicit-any 같은 규칙을 켤 때도 예외 정책이 필요합니다.
// 허용 가능: 테스트에서 라이브러리 mock이 지나치게 복잡한 경우
const mockClient = createClientMock() as any;
// 위험: 실제 서비스 반환 타입을 any로 열어두는 경우
export async function getCheckoutSession(): Promise<any> {
return fetchJson("/api/checkout/session");
}
리뷰 기준은 “몇 개인가?”보다 “어디까지 퍼지는가?”에 가깝습니다.
Item 42–48을 읽을 때 any만 보이면 범위가 좁아집니다. 이 구간의 더 큰 질문은 타입스크립트가 일부러 완전한 증명을 포기한 지점입니다. 구조적 타입, 배열, 콜백, DOM, 외부 라이브러리는 모두 런타임 현실과 타입 선언 사이에 틈을 만들 수 있습니다.
type MenuItem = { id: string; label: string };
function renderMenu(items: MenuItem[]) {
items.push({ id: "debug", label: "Debug" });
}
const readonlyItems: readonly MenuItem[] = [
{ id: "home", label: "Home" },
{ id: "settings", label: "Settings" },
];
// renderMenu(readonlyItems); // readonly라서 막힌다
이 예제의 포인트는 readonly를 외우는 것이 아닙니다. 함수가 배열을 읽기만 하는지, 내부에서 바꿀 수 있는지 계약으로 드러내야 한다는 점입니다. 매개변수를 readonly MenuItem[]로 받으면 호출자는 “이 함수가 내 배열을 바꾸지 않겠구나”라고 더 안전하게 믿을 수 있습니다.
function renderMenu(items: readonly MenuItem[]) {
return items.map((item) => item.label).join(" / ");
}
비슷하게 콜백 타입도 너무 넓게 받으면 안전하지 않습니다.
type Article = { id: string; title: string };
type DraftArticle = Article & { status: "draft" };
function publishAll(articles: Article[], publish: (article: Article) => void) {
for (const article of articles) publish(article);
}
const publishDraft = (draft: DraftArticle) => {
console.log(draft.status.toUpperCase());
};
publishAll은 모든 Article을 넘길 수 있는데, publishDraft는 DraftArticle만 안전하게 처리합니다. 실제 코드에서는 strict function type 설정, 라이브러리 타입 선언, 콜백 위치에 따라 이런 틈이 다르게 보일 수 있습니다. 코드 리뷰에서는 “타입스크립트가 통과시켰다”에서 끝내지 말고, 함수가 실제로 어떤 값을 받을 수 있는지를 다시 봐야 합니다.
직접 type guard를 매번 쓰면 지저분해질 수 있습니다. 프로젝트에서는 Zod, Valibot, ArkType 같은 스키마 라이브러리를 쓰거나 서버 DTO에서 타입을 생성하기도 합니다. 도구 선택보다 중요한 것은 검증 결과가 내부 타입으로 연결되는 구조입니다.
const UserSchema = z.object({
id: z.string(),
profile: z.object({ displayName: z.string() }),
});
type UserPayload = z.infer<typeof UserSchema>;
async function loadUserSafe(id: string): Promise<UserPayload> {
const data: unknown = await fetchJson(`/api/users/${id}`);
return UserSchema.parse(data);
}
이 방식은 “런타임 검증”과 “타입 추론”을 한 위치에 묶습니다. 단, 스키마 라이브러리를 쓴다고 자동으로 안전해지는 것은 아닙니다. parse 실패를 어디서 사용자 메시지로 바꾸는지, safeParse 결과를 무시하지 않는지, 서버와 클라이언트가 같은 필드 의미를 공유하는지까지 봐야 합니다.
const parsed = UserSchema.safeParse(data);
if (!parsed.success) {
return { ok: false, reason: "invalid_payload" } as const;
}
return { ok: true, user: parsed.data } as const;
이렇게 실패 경로가 값으로 남으면 UI는 “타입이 맞지 않는 데이터”를 예외로만 다루지 않아도 됩니다. Day 6의 유효 상태 모델링과 Day 8의 unknown 경계가 만나는 지점입니다.
브라우저 전역 객체나 라이브러리 객체에 필드를 붙이는 코드는 타입스크립트에서 두 겹의 위험을 만듭니다.
(window as any).analyticsClient = createClient();
런타임 전역 상태도 생기고, 타입 시스템도 우회합니다. 정말 전역 확장이 필요하다면 최소한 타입 선언과 초기화 경계를 같이 둬야 합니다.
declare global {
interface Window {
analyticsClient?: AnalyticsClient;
}
}
window.analyticsClient = createClient();
그래도 리뷰 질문은 남습니다. “전역이어야 하는가?”, “초기화 전 접근은 어떻게 막는가?”, “테스트에서 격리되는가?”입니다.
가능하면 전역 확장 대신 주입 경계를 둡니다.
export function createAnalyticsEvents(client: AnalyticsClient) {
return {
articleViewed(slug: string) {
client.track("article_viewed", { slug });
},
};
}
이렇게 하면 타입 선언 파일을 넓히지 않고도 테스트에서 fake client를 넘길 수 있습니다. monkey patching은 “타입 에러만 없애는 빠른 수정”이 아니라 전역 계약을 바꾸는 일로 봐야 합니다.
any가 외부 경계에서 끝나는가, 아니면 컴포넌트 내부 props/state까지 번지는가?unknown을 받은 뒤 실제 검증 또는 narrowing이 있는가?as SomeType이 런타임 검증처럼 쓰이고 있지 않은가?any가 사용자 데이터, 인증, 결제, 공개 API 타입에 가까운가?any/단언으로 침묵시키고 있지 않은가?any를 완전히 금지하자는 뜻은 아닙니다. 중요한 건 any가 어디서 들어오고 어디서 멈추는지입니다. 외부 입력은 unknown으로 받고, 경계에서 검증하고, 내부에는 도메인 타입만 흘려보내는 쪽이 리뷰 가능한 코드에 가깝습니다.
unknown 검증 실패를 ok: false 같은 상태로 표현할 때 다시 필요합니다.HTMLElement | null, 서버/브라우저 환경 차이처럼 타입스크립트가 런타임 환경을 다 알 수 없는 지점을 다룹니다.Post Q&A
이펙티브 타입스크립트 2판 Day 8: any를 밖에 세우고 unknown으로 검증하기 전체를 기준으로 질문과 피드백을 받아요.답을 본 뒤에는 이 내용을 댓글로 달아서 서징에게도 물어볼 수 있어요. 작성자가 직접 볼 수 있어요!
Item 74–78 범위를 바탕으로 런타임 타입 재구성, DOM과 환경 모델, 단위 테스트 관계, 컴파일러 성능을 코드 리뷰 관점에서 정리합니다.
Item 79–83 범위를 바탕으로 JS 현대화, @ts-check와 JSDoc, allowJs, 모듈별 마이그레이션, noImplicitAny를 코드 리뷰 관점에서 정리합니다.
Item 67–73 범위를 바탕으로 public API 타입, TSDoc, this callback, module augmentation, TypeScript 기능 선택, source map을 코드 리뷰 관점에서 정리합니다.
Item 60–66 범위를 바탕으로 Object iteration, Record, tuple/rest, XOR, brand, @types 버전 경계를 코드 리뷰에서 다루는 방법을 정리합니다.
Item 55–59 범위를 바탕으로 type test, 타입 표시, 꼬리 재귀 한계, 코드 생성, never exhaustiveness를 코드 리뷰에서 다루는 방법을 정리합니다.
Item 49–54 범위를 바탕으로 type coverage, 제네릭, 조건부 타입, 템플릿 리터럴 타입을 코드 리뷰에서 어떻게 판단할지 정리합니다.
Item 42–48 범위를 바탕으로 any, unknown, 타입 단언, monkey patching, soundness 함정을 외부 입력 경계와 코드 리뷰 관점에서 정리합니다.
Item 35–41 범위를 바탕으로 string 남용, optional 필드, 특수 값, 도메인 이름 설계를 코드 리뷰 관점에서 정리합니다.
Item 28–34 범위를 바탕으로 추론 위치, 유효 상태 모델링, API 입출력, null 경계, union 설계를 프론트엔드 코드 리뷰 관점에서 확장 정리합니다.
Item 22–27 범위를 바탕으로 narrowing, alias, context inference, evolving type, async/type flow를 프론트엔드 코드 리뷰 관점에서 다시 정리합니다.
Effective TypeScript 2판의 Item 16–21을 바탕으로 index signature, 타입 추론 기본, 변수/객체 생성 패턴을 프론트엔드 코드 리뷰 관점에서 정리합니다.
Effective TypeScript 2판의 Item 11–15를 바탕으로 excess property check, 함수식 타입, type vs interface, readonly, 타입 반복 제거를 프론트엔드 코드 리뷰 관점에서 정리합니다.
Effective TypeScript 2판의 Item 6–10을 바탕으로 타입 시스템을 탐색하는 법, 타입을 값의 집합으로 보는 관점, type/value space, 타입 단언의 경계를 코드 리뷰 관점에서 정리합니다.
Effective TypeScript 2판의 Item 1–5를 바탕으로 TypeScript와 JavaScript의 관계, tsconfig, 타입 제거, 구조적 타이핑, any의 위험을 프론트엔드 코드 리뷰 관점에서 정리합니다.