광고
~ / posts / developer

타입스크립트 타입 좁히기 고르는 법, 타입 가드 판단표와 안 될 때 점검

typeof·판별 유니언·타입 술어·asserts·as 중 무엇을 쓸지 값의 출처로 고르는 판단표, 좁히기가 안 먹을 때와 좁혀졌는데 런타임에 터질 때의 점검 순서를 정리했어요. if 사슬과 switch 검사 시간, TS2563 한계(if 2,000개)를 직접 쟀어요.

if (typeof x === "string") 정도는 누구나 쓰지만, 실무에서 고민되는 건 그다음이에요. API 응답에 타입 술어를 붙일지 as 로 넘길지, 결제 수단 같은 유니언에 멤버를 추가할 때 빠뜨린 분기를 어떻게 잡을지, 분명 검사했는데 왜 빨간 줄이 안 사라지는지요. 결론부터 말하면 좁히기 수단은 "그 값의 모양을 누가 보장하나"로 고르면 돼요. 내 코드가 만든 값이면 판별 유니언과 switch, 바깥에서 들어온 값이면 런타임 검사를 거친 뒤에만 좁히고, as 와 손으로 쓴 x is T 는 컴파일러가 검사하지 않는 약속이라 마지막 수단으로 남겨요.

이 글은 좁히기의 내부 동작은 알고 있다고 보고, 고르는 기준·점검 순서·실제 숫자만 다뤄요. 예제는 모두 TypeScript 6.0.3과 7.0.2에서 --strict 로 컴파일해 결과를 확인했고, 검사 시간은 Apple M1(8코어, 메모리 8GB)·Node 25.3에서 세 번 잰 값 중 가장 빠른 값이에요.

광고

타입 좁히기 수단 판단표: 값의 출처로 고르기

좁히기 수단마다 "틀렸을 때 누가 잡아 주나"가 달라요. 그래서 문법보다 값이 어디서 왔는지를 먼저 봐요.

상황쓸 것피할 것이유
원시 값 유니언 (`string \number`)typeof, ===, Array.isArrayas string런타임 검사 자체가 근거라 틀릴 수 없어요
상태·이벤트·결제 수단처럼 종류가 정해진 객체판별 유니언 + switch"cardLast4" in p 로 속성 유무 검사리터럴 판별자는 멤버 추가 시 철저성 검사로 잡혀요
클래스 인스턴스·에러 객체instanceof(e as AxiosError).response프레임(iframe·워커)이 다르면 instanceof 가 거짓이 될 수 있어 경계에서만 조심
배열에서 null·undefined 거르기 (5.5 이상)filter((x) => x !== null)filter(Boolean), filter((x) => !!x)앞쪽만 타입 술어가 추론돼요. !!x 는 0·"" 도 버려요
API 응답·JSON.parse·localStorage 값검증 함수 하나(스키마 검증 라이브러리 포함) → 그 결과로만 좁히기응답에 바로 as User서버가 필드를 바꿔도 컴파일은 통과해요
테스트·초기화처럼 "여기서 아니면 멈춰야 하는" 지점asserts v is T 함수매번 ! 단언실패하면 그 자리에서 예외가 나서 원인이 가까워요
컴파일러가 증명할 수 없지만 내가 확실한 곳as (주석으로 근거)근거 없는 as 남발마지막 수단. 린트로 개수를 관리해요

API 응답을 다룰 때는 JSON → TypeScript 변환기로 샘플에서 타입을 뽑는 게 출발점이지만, 그 타입은 "이렇게 생겼으면 좋겠다"는 선언일 뿐이에요. 실제 응답이 그 모양인지는 런타임 검사로 한 번 확인해야 하고, 응답이 비거나 모양이 다를 때 어디부터 볼지는 API 응답 디버깅 6단계에 정리해 두었어요.

값을 내 코드가 만들었는지, 원시 값인지, 종류가 정해진 객체인지, 바깥에서 들어온 값인지를 차례로 물어 typeof·판별 유니언과 switch·검증 함수 후 좁히기·asserts 함수·as 중 하나로 안내하는 순서도
아래로 내려갈수록 컴파일러가 대신 확인해 주는 범위가 줄어요

명시적 타입 술어(x is T)는 언제 써도 되나

TypeScript 5.5 릴리스 노트는 명시적 타입 술어가 타입 단언(as)보다 안전하지 않다고 적어요. 컴파일러는 본문이 return typeof v === "number" 인지 return true 인지 보지 않아요. 그래서 실무 기준은 두 가지예요.

판별 유니언과 철저성 검사 방법 비교

판별 유니언은 멤버마다 공통 속성(method, type, status 등)에 문자열 리터럴을 두는 형태예요. 판별자가 string 으로 넓혀져 있으면 아무것도 좁혀지지 않으니, 서버 응답을 매핑할 때 method: string 이 아니라 method: "card" | "transfer" 로 받아야 해요. 판별자는 모든 멤버에 필수 속성으로 두는 게 안전해요.

멤버를 추가했을 때 빠뜨린 분기를 잡는 방법은 네 가지가 있어요. 아래 예제의 Payment 에 "point" 를 추가하고 case 는 안 늘린 상태로 각각 확인했어요.

type Payment =
  | { method: "card"; cardLast4: string }
  | { method: "transfer"; bank: string }
  | { method: "point" }; // 새로 추가

function label(p: Payment): string {
  switch (p.method) {
    case "card": return `카드 ${p.cardLast4}`;
    case "transfer": return `${p.bank} 이체`;
    default: {
      const unhandled: never = p; // TS2322: '{ method: "point"; }' 은 never 에 못 넣음
      throw new Error(`처리 안 한 결제 수단: ${JSON.stringify(unhandled)}`);
    }
  }
}
방법빠뜨렸을 때 나오는 것오류에 빠진 멤버가 보이나런타임 대비비용
반환 타입 주석만 (default 없음)TS2366 함수 끝에 return 없음아니요없음0
default 에서 const x: never = pTS2322예 ({ method: "point" })throw 를 같이 쓰면 있음2줄
default 에서 p satisfies never (4.9 이상)TS1360예throw 와 함께1줄
typescript-eslint switch-exhaustiveness-check린트 오류예없음타입 정보 린트 설정

반환 값이 없는 함수(void)에는 첫 번째 방법이 통하지 않아요. 그래서 기본은 never 대입 + throw 를 추천해요. 서버가 아직 모르는 값을 보내는 경우까지 런타임에서 걸러 주기 때문이에요. 함수가 많아 매번 default 를 쓰기 번거롭다면 switch-exhaustiveness-check 규칙을 켜요. 문서상 기본값(considerDefaultExhaustiveForUnions: false)에서는 default 가 있어도 빠진 case 를 알려 주고, allowDefaultCaseForExhaustiveSwitch 를 false 로 바꾸면 모든 case 를 다 쓴 switch 의 불필요한 default 까지 막아요.

숫자로 보는 비용: if 사슬 vs switch 검사 시간

판별 유니언 멤버가 수백 개가 되는 경우는 생각보다 흔해요. 이벤트 로그 타입, 생성된 API 클라이언트의 응답 코드, 리듀서의 액션 타입이 그래요. 멤버 N개짜리 유니언을 만들고, 같은 분기를 if (u.kind === "k0") return … 를 N번 이어 쓴 버전과 switch 의 case N개로 쓴 버전의 Check time을 쟀어요(--skipLibCheck, 파일 하나).

멤버 수if 사슬 6.0.3if 사슬 7.0.2switch 6.0.3switch 7.0.2
500.20초0.04초0.04초0.001초
1000.49초0.36초0.07초0.003초
2002.12초2.76초0.07초0.004초
3006.87초9.30초0.15초0.010초
40018.78초27.33초0.17초0.021초
판별 유니언 멤버 수별 검사 시간 — if 사슬 vs switch
if 사슬 6.0.3if 사슬 7.0.2switch 6.0.3switch 7.0.2
0510152025305010020030040050 · if 사슬 6.0.3 0.2초100 · if 사슬 6.0.3 0.49초200 · if 사슬 6.0.3 2.1초300 · if 사슬 6.0.3 6.9초400 · if 사슬 6.0.3 18.8초18.850 · if 사슬 7.0.2 0.04초100 · if 사슬 7.0.2 0.36초200 · if 사슬 7.0.2 2.8초300 · if 사슬 7.0.2 9.3초400 · if 사슬 7.0.2 27.3초27.350 · switch 6.0.3 0.04초100 · switch 6.0.3 0.07초200 · switch 6.0.3 0.07초300 · switch 6.0.3 0.15초400 · switch 6.0.3 0.17초0.1750 · switch 7.0.2 0.001초100 · switch 7.0.2 0.003초200 · switch 7.0.2 0.004초300 · switch 7.0.2 0.01초400 · switch 7.0.2 0.021초0.021

switch는 1,600개 0.49초·0.17초, 3,200개 1.27초·0.70초로 여전히 짧았어요.

단위: 초 · 자료: Apple M1 8코어·메모리 8GB, Node 25.3, tsc --strict --skipLibCheck Check time 세 번 중 최솟값으로 직접 측정 (2026-10-29)
표로 보기
구분if 사슬 6.0.3if 사슬 7.0.2switch 6.0.3switch 7.0.2
500.20.040.040.001
1000.490.360.070.003
2002.12.80.070.004
3006.99.30.150.01
40018.827.30.170.021

if 사슬은 멤버가 200개에서 400개로 두 배가 되자 검사 시간이 8.9배(6.0.3)·9.9배(7.0.2)가 됐어요. 체커는 if 마다 u.kind 를 읽을 때 앞선 거짓 조건을 거슬러 올라가며 유니언을 깎으니, 조건 수와 멤버 수가 곱으로 쌓여요. switch 는 case 범위 단위로 한 번에 깎아서 3,200개에서도 1초 안팎이었어요. 또 if 사슬에서는 7.0.2가 6.0.3보다 느렸어요. 7.0은 여러 파일을 나눠 검사해서 빨라지는데, 함수 하나에 몰린 계산은 나눌 수 없어요. tsc가 이유 없이 느리다면 --extendedDiagnostics 와 --generateTrace 로 위치를 찾는 순서를 타입스크립트 strict 옵션과 tsc 검사 한계에 정리해 두었어요.

흐름 분석이 꺼지는 한계: TS2563

한 함수(또는 모듈 최상위)에 if (c()) { y = i; } 를 N개 이어 붙이고 마지막에 매개변수를 읽었어요. 1,999개는 통과, 2,000개부터 두 버전 모두 TS2563 "The containing function or module body is too large for control flow analysis" 를 냈어요. 이 오류가 나면 그 본문 전체의 좁히기가 꺼져서, 멀쩡하던 줄에 "possibly null" 오류가 우르르 쏟아져요. 손으로 짠 코드에서는 드물고, 코드 생성기가 만든 거대한 함수나 수천 줄짜리 설정 스크립트에서 나요. 함수를 쪼개거나 분기를 데이터(객체 테이블)로 바꾸면 풀려요.

const 별칭 조건은 5단계까지

const isStr = typeof x === "string" 처럼 조건을 상수에 담아도 좁혀져요. TypeScript 4.4 릴리스 노트는 상수를 건너뛰며 따라가되 "임의로 깊이 가지는 않는다"고만 적어요. 직접 재 보니 a1 = typeof x === "string", a2 = a1 … 처럼 이어 갈 때 a5 까지는 좁혀지고 a6 부터 안 됐어요. 실무에서는 두세 단계면 충분하니 별칭을 별칭으로 다시 감싸는 일은 피하세요.

런타임 비용은 걱정할 수준이 아니에요

타입 가드를 함수로 빼면 느려지지 않을까 하는 질문도 많아요. 같은 루프를 1,000만 번 돌려 쟀어요.

검사 1,000만 번에 걸린 시간 (Node 25.3)
인라인 typeof인라인 typeof · 53.9ms53.9ms술어 함수 isString()술어 함수 isString() · 47.6ms47.6ms객체 모양 검사 4단계객체 모양 검사 4단계 · 190ms190ms

함수로 빼도 차이가 없었고(JIT 인라인), 객체 모양 검사도 한 번에 약 19나노초예요.

단위: ms · 자료: Apple M1에서 다섯 번 중 최솟값으로 직접 측정 (2026-10-29). 객체 검사는 object·null·kind·필드 타입 확인
표로 보기
구분시간
인라인 typeof53.9
술어 함수 isString()47.6
객체 모양 검사 4단계190

검사 한 번이 수십 나노초라 바깥 값마다 검사해도 응답 시간에 드러나지 않아요. 비용 때문에 as 를 고를 이유는 없어요.

타입이 좁혀지지 않을 때 점검 순서

"분명 if 로 검사했는데 오류가 남아요"의 원인은 거의 아래 일곱 가지 안에 있어요. 위에서부터 확인하면 빨라요.

좁혀지지 않을 때 확인할 일곱 가지(참조 모양, 재대입, 콜백과 나중 대입, 별칭이 let이거나 너무 깊음, filter 술어 추론 조건, asserts 함수 타입 표기, TS2563)와 좁혀졌는데 런타임에 틀릴 때 확인할 네 가지(호출 뒤, await 뒤, 명시적 술어, as)를 두 칸으로 나눈 체크리스트
왼쪽은 컴파일 오류가 남는 경우, 오른쪽은 컴파일은 되는데 틀리는 경우예요
  1. 참조가 좁힐 수 있는 모양인가. 변수, a.b.c 같은 점 경로, 그리고 5.5부터는 obj[key] 에서 obj 와 key 가 둘 다 바뀌지 않을 때만 좁혀져요. o[k()] 처럼 함수 호출로 만든 키는 매번 다른 값일 수 있어 안 돼요. 결과를 const v = o[k()] 에 담고 v 를 검사하세요.
  2. 검사와 사용 사이에 재대입이 있나. 함수 안에서 key = key.trim() 처럼 한 번이라도 다시 대입한 매개변수는 obj[key] 좁히기 대상에서 빠졌어요. 새 const 로 받으세요.
  3. 콜백 안이라면 그 변수를 나중에 또 대입하나. TypeScript 5.4 릴리스 노트대로 마지막 대입 뒤에 만든 화살표 함수는 좁혀진 타입을 봐요. 그런데 함수 끝에 y = 1 같은 대입이 하나라도 있으면 그 앞의 forEach 콜백에서도 좁히기가 사라졌어요. 함수 선언(function inner())은 호이스팅 때문에 늘 안 돼요.
  4. 조건을 담은 변수가 let 이거나 5단계보다 깊은가. let isStr = … 은 따라가지 않아요. const 로 바꾸세요. 구조 분해한 판별자도 const { method } = p 여야 원래 객체 p 가 좁혀져요.
  5. filter 결과가 안 좁혀지나. 콜백이 x !== null 처럼 "참이면 T, 거짓이면 T 아님"을 만족해야 술어가 추론돼요. filter(Boolean) 은 6.0.3·7.0.2 모두 (string | undefined)[] 그대로였어요.
  6. 화살표 함수로 만든 asserts 함수인가. const assertStr = (v: unknown): asserts v is string => … 로 만들고 부르면 TS2775 "Assertions require every name in the call target to be declared with an explicit type annotation" 이 나요. function 선언으로 쓰거나 변수에 (v: unknown) => asserts v is string 타입을 직접 적어야 해요.
  7. TS2563이 같이 떠 있나. 위의 2,000개 한계예요. 함수를 쪼개세요.

예외 두 가지도 알아 두면 좋아요. readonly string[] | string 에 Array.isArray 를 쓰면 참 갈래는 되지만 거짓 갈래가 string 으로 안 좁혀졌어요(두 버전 모두). typeof x === "string" 쪽으로 검사하면 풀려요. 반대로 5.3부터는 switch (true) 의 case typeof x === "string": 도 좁혀지니 굳이 if 사슬로 바꿀 필요가 없어요(TypeScript 5.3 릴리스 노트).

광고
광고

좁혀졌는데 런타임에 틀릴 때 점검 순서

더 위험한 건 컴파일은 통과하는데 실행에서 터지는 쪽이에요.

let current: { name: string } | null = null;
function logout() { current = null; }

export function greet() {
  if (current !== null) {
    logout();
    return current.name; // 컴파일 통과, 실행하면 TypeError
  }
  return "";
}

① 상태를 바꾸는 함수 호출 뒤. 컴파일러는 일반 함수 호출이 바깥 변수나 객체 속성을 바꿀 수 있다는 걸 반영하지 않아요. 호출마다 좁히기를 지우면 정상 코드에 오류가 쏟아지기 때문에 일부러 낙관적으로 둔 설계예요(이슈 #9998 Trade-offs in Control Flow Analysis). 모듈 변수·싱글턴 상태·this 속성을 바꾸는 호출 뒤에는 다시 검사하거나, 검사 결과를 const 에 받아 두고 그 값을 쓰세요.

② await 뒤. 클래스 메서드에서 if (this.token !== null) { await fetch(…); this.token.length } 도 통과했어요. 기다리는 동안 다른 작업이 this.token = null 로 바꿀 수 있어요. 토큰 갱신·로그아웃이 섞이는 코드에서 자주 나는 문제예요. const token = this.token 으로 받은 뒤 검사하세요.

③ 손으로 쓴 x is T. 본문이 틀려도 통과해요. 참·거짓 입력 테스트가 있는지 확인하세요.

④ as 와 !. 특히 JSON.parse(...) as T, res.json() as Promise<T>. 검증 없이 바깥 값을 단언했다면 여기가 원인일 가능성이 높아요. JSON 파싱 에러 원인 6가지처럼 파싱 자체가 실패하는 경우와 달리, 이 경우는 파싱은 되고 필드만 달라서 한참 뒤에 터져요.

도입 전에 따질 것: 업그레이드와 린트 규칙

5.5 이상으로 올리면 filter 결과 타입이 좁아져요. 5.5 릴리스 노트의 예처럼 [1, null].filter((x) => x !== null) 이 number[] 가 되면서, 그 배열에 나중에 null 을 넣던 코드가 새로 오류가 나요. 업그레이드 PR에서 이런 오류가 나오면 원래 의도한 타입((number | null)[])을 변수에 직접 적어 주면 돼요.

린트로 단언을 관리할지 정해요. typescript-eslint의 no-unnecessary-condition은 이미 좁혀져 항상 참·거짓인 검사를 찾아 주고(strict-type-checked 설정에 포함), no-unnecessary-type-assertion은 필요 없는 as·! 를 지워 줘요(recommended-type-checked 포함). 둘 다 문서상 타입 정보가 필요한 규칙이라 린트 시간이 늘어요. 큰 저장소라면 CI에서만 돌리는 것도 방법이에요. 단언 자체를 줄이려면 consistent-type-assertions의 objectLiteralTypeAssertions: "never" 로 { … } as T 를 막을 수 있어요.

외부 입력 경계를 한 곳으로 모아요. fetch 래퍼, 메시지 큐 소비자, localStorage 읽기마다 검증 함수를 두고 나머지 코드는 좁혀진 타입만 받게 하면, 위 ③·④를 볼 범위가 크게 줄어요.

TypeScript 7을 쓰는 팀은 린트 도구 호환을 확인해요. 좁히기 결과는 이 글의 모든 예제에서 6.0.3과 7.0.2가 같았어요. 다만 타입 정보 린트는 컴파일러 API에 기대는데, 7.0에는 아직 그 API가 없어 6.0 패키지와 함께 두는 구성이 필요해요.

광고
광고

자주 묻는 질문

타입 술어(x is T)와 as 중 무엇이 더 안전한가요?

컴파일러 입장에서는 둘 다 검사하지 않는 약속이라 같아요. 차이는 술어가 검사 코드를 한 함수에 모아 테스트할 수 있게 해 준다는 점이에요. 5.5 이상이면 반환 타입을 비워 추론되게 하는 게 가장 안전해요.

switch 대신 if 로 판별 유니언을 나눠도 되나요?

멤버가 수십 개 이하면 상관없어요. 이 글 측정에서 50개는 if 사슬도 0.2초 이하였어요. 수백 개가 되면 if 사슬은 400개에서 18~27초까지 늘었고 switch 는 0.2초 이하라, 큰 유니언은 switch 로 쓰세요.

TS2563 too large for control flow analysis 는 어떻게 고치나요?

한 함수나 모듈 최상위에 분기가 수천 개 이어질 때 나요. 이 글 측정에서는 if 2,000개부터 났어요. 생성된 코드라면 생성기를 고쳐 함수를 나누고, 손으로 쓴 코드라면 분기를 객체 테이블 조회로 바꾸세요.

함수를 부른 뒤에도 좁혀진 타입이 유지되는 게 버그 아닌가요?

의도된 설계예요. 호출마다 좁히기를 지우면 대부분의 정상 코드가 오류가 나서, TypeScript 팀은 호출이 상태를 안 바꾼다고 가정하는 쪽을 택했어요. 상태를 바꾸는 호출이나 await 뒤에는 다시 검사하거나 지역 const 복사본을 쓰세요.

철저성 검사는 never 대입과 린트 규칙 중 무엇이 좋나요?

never 대입 + throw 는 설정 없이 쓰고 런타임에 모르는 값까지 막아 줘요. 린트 규칙은 default 를 안 써도 돼서 함수가 많을 때 편해요. 둘을 같이 써도 충돌하지 않아요.

정리

더 깊이 읽기

이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 타입스크립트 타입 좁히기 원리 — 제어 흐름 분석과 플로 노드에서 도식과 코드로 단계별로 풀었어요.

참고한 자료

#타입스크립트 타입 좁히기#타입 가드 언제 쓰나#판별 유니언 철저성 검사#타입 술어 vs as#asserts 함수 TS2775#TS2563 too large for control flow analysis#타입 좁히기 안 될 때#switch exhaustive never
← 이전 글몽고DB 인덱스 잘못 탈 때 대처법, hint·쿼리 설정·플랜 캐시 판단표다음 글 →허프만 압축 언제 쓰나, zlib 전략·코드 길이 제한·작은 데이터 손익 계산
광고

더 둘러보기

💻 개발자 다른 도구

🔥 많이 찾는 도구

🔮 재미로 한 번 더

📝 읽어 볼 만한 글

글 전체 보기 →