타입스크립트를 쓰다 보면 하나의 변수가 여러 타입을 가질 수 있는 상황을 자주 만나게 됩니다. 함수의 매개변수가 string일 수도, number일 수도 있고, API 응답이 성공 데이터일 수도, 에러 객체일 수도 있습니다. 이처럼 다양한 가능성을 안고 있는 변수를 다룰 때, 타입스크립트는 꽤 보수적으로 반응합니다. 해당 변수가 “어떤 타입인지 아직 모르겠으니” 특정 타입 전용 메서드를 사용하지 못하도록 막아버리는 것입니다.
이 문제를 해결하는 방법이 바로 타입 좁히기(Type Narrowing)입니다. 타입 좁히기란 조건문 등을 활용해 넓은 타입을 더 구체적인 타입으로 확정해가는 과정을 말합니다. 복잡하게 들릴 수 있지만, 사실 우리가 자바스크립트에서 이미 자연스럽게 하고 있던 방어적 코딩 패턴을 타입스크립트가 인식하고 활용해주는 것에 가깝습니다.
이번 글에서는 타입 좁히기가 왜 필요한지부터 실무에서 자주 쓰이는 핵심 패턴까지 단계별로 살펴보겠습니다.
미리 요점만 콕 집어보면?
타입 좁히기가 왜 필요한지 이해하려면, 먼저 유니온 타입의 한계부터 살펴봐야 합니다.
유니온 타입은 여러 타입 중 하나가 될 수 있는 변수를 정의할 때 사용합니다. 예를 들어, 문자열과 숫자를 모두 받을 수 있는 함수를 만든다고 해봅시다.
function printValue(value: string | number) {
console.log(value.toUpperCase()); // ❌ 오류
}
이 코드는 에러가 발생합니다. value가 string일 수도, number일 수도 있기 때문에 타입스크립트는 string 전용 메서드인 toUpperCase()를 호출하지 못하게 막는 것입니다. 반대로 number 전용 메서드인 toFixed()도 마찬가지로 쓸 수 없습니다. 유니온 타입의 변수에는 모든 구성 타입이 공통으로 갖고 있는 프로퍼티와 메서드만 사용할 수 있기 때문입니다.
그렇다면 어떻게 해야 할까요? 이 변수가 string인지 number인지를 먼저 확인하고, 확인된 상태에서 해당 타입의 기능을 사용하면 됩니다. 바로 이것이 타입 좁히기입니다.
아래처럼 typeof를 사용한 조건문을 추가해 봅시다.
function printValue(value: string | number) {
if (typeof value === "string") {
console.log(value.toUpperCase()); // ✅ string 타입으로 확정
} else {
console.log(value.toFixed(2)); // ✅ number 타입으로 확정
}
}
if 블록 안에서 typeof value === "string" 조건을 통과했다면, 타입스크립트는 해당 블록 안의 value를 string 타입으로 확정합니다. else 블록에서는 string이 아닌 나머지, 즉 number 타입으로 확정됩니다. 별도의 타입 단언(as)을 쓰지 않아도, 조건문만으로 타입을 안전하게 구분할 수 있는 것입니다.

이렇게 조건문 등을 활용하여 타입의 범위를 좁혀나가는 표현들을 타입 가드(Type Guard)라고 부릅니다. 타입 좁히기를 실현하는 구체적인 도구가 바로 타입 가드인 셈입니다. 이제 실무에서 자주 쓰이는 타입 가드 패턴들을 하나씩 살펴보겠습니다.
타입스크립트에서 제공하는 타입 가드는 여러 종류가 있습니다. 상황에 따라 적합한 패턴이 다르기 때문에, 각 패턴의 특성과 사용 시점을 정리해두면 유용합니다.
typeof는 가장 기본적이고 자주 사용되는 타입 가드입니다. 자바스크립트의 typeof 연산자가 반환하는 문자열을 기준으로 타입을 구분합니다.
function formatInput(input: string | number | boolean) {
if (typeof input === "string") {
return input.trim();
} else if (typeof input === "number") {
return input.toLocaleString();
} else {
return input ? "참" : "거짓";
}
}
typeof는 "string", "number", "boolean", "undefined", "object", "function", "symbol", "bigint" 중 하나를 반환합니다. 주의할 점은 typeof null이 "object"를 반환한다는 것입니다. 따라서 null과 객체를 동시에 다루는 경우에는 typeof만으로는 정확하게 구분하기 어렵습니다. null 체크를 별도로 추가하거나, 다른 타입 가드를 함께 사용해야 합니다.
function process(value: string | null) {
if (typeof value === "string") {
return value.toUpperCase(); // ✅ null이 아님이 보장됨
}
return "빈 값";
}
typeof는 원시 타입을 구분할 때 유용하지만, 객체의 구체적인 종류(Date인지, Array인지, 커스텀 클래스인지)를 구분하기에는 적합하지 않습니다. 객체는 typeof로 확인하면 전부 "object"가 반환되기 때문입니다. 이런 경우에는 다음에 소개할 instanceof를 사용해야 합니다.
instanceof는 어떤 값이 특정 클래스의 인스턴스인지를 확인하는 연산자입니다. 주로 Date, Error 같은 내장 클래스나 직접 정의한 클래스의 인스턴스를 구분할 때 사용합니다.
function formatDate(value: string | Date) {
if (value instanceof Date) {
return `${value.getFullYear()}년 ${value.getMonth() + 1}월 ${value.getDate()}일`;
}
return value;
}
에러 처리에서도 자주 쓰입니다. try-catch 블록에서 catch한 에러가 어떤 종류인지 구분할 때 instanceof가 유용합니다.
function handleError(error: unknown) {
if (error instanceof TypeError) {
console.log("타입 에러:", error.message);
} else if (error instanceof RangeError) {
console.log("범위 에러:", error.message);
} else {
console.log("알 수 없는 에러");
}
}
다만 instanceof는 클래스 기반 타입에만 사용할 수 있습니다. 인터페이스나 타입 별칭 그 자체는 컴파일 과정에서 제거되어 런타임에 남지 않으므로, 이를 직접 instanceof로 검사할 수는 없습니다. 즉, 자바스크립트로 변환된 코드에는 인터페이스에 대한 정보가 남아있지 않으므로 instanceof로 비교할 대상 자체가 없는 것입니다. 물론 해당 인터페이스를 구현한 클래스가 있다면, 그 클래스를 기준으로 instanceof 검사를 하는 것은 가능합니다. 하지만 인터페이스 자체를 직접 검사할 수 없다는 점은 동일합니다.
in 연산자는 객체에 특정 프로퍼티가 존재하는지를 확인하는 방식으로 타입을 좁힙니다. 인터페이스나 타입 별칭으로 정의한 서로 다른 객체 타입을 구분할 때 특히 유용합니다.
interface Dog {
name: string;
bark: () => void;
}
interface Cat {
name: string;
meow: () => void;
}
function greetPet(pet: Dog | Cat) {
if ("bark" in pet) {
pet.bark(); // ✅ Dog 타입으로 확정
} else {
pet.meow(); // ✅ Cat 타입으로 확정
}
}
Dog에는 bark가 있고 Cat에는 없기 때문에, "bark" in pet 조건으로 두 타입을 구분할 수 있습니다. 이처럼 in 연산자는 타입마다 고유하게 존재하는 프로퍼티를 기준으로 분기할 때 효과적입니다. 다만, 두 타입이 완전히 동일한 프로퍼티 구조를 갖고 있다면 in만으로는 구분이 어렵습니다. 이런 경우에는 뒤에서 다룰 판별된 유니온 패턴이 더 적합합니다.
기본 패턴을 정리하면 다음과 같습니다.

앞서 살펴본 기본 패턴만으로도 많은 상황을 커버할 수 있지만, 실무에서 마주하는 타입 분기는 조금 더 복잡한 경우가 많습니다. 여러 종류의 응답 객체를 구분하거나, 타입스크립트가 자동으로 추론하지 못하는 상황을 직접 처리해야 할 때가 있습니다. 이런 경우에 활용할 수 있는 고급 패턴들을 살펴보겠습니다.

실무에서 가장 많이 쓰이는 타입 좁히기 패턴입니다. 유니온을 구성하는 각 타입이 공통적으로 가지고 있는 리터럴 타입 필드를 기준으로 분기하는 방식입니다. 보통 type, status, kind 같은 필드가 이 역할을 합니다.
API 응답 처리를 예로 들어보겠습니다. 대부분의 API는 성공과 실패 시 응답 구조가 다릅니다.
interface SuccessResponse {
status: "success";
data: {
id: number;
name: string;
};
}
interface ErrorResponse {
status: "error";
errorCode: number;
message: string;
}
type ApiResponse = SuccessResponse | ErrorResponse;
여기서 status 필드가 판별자(discriminant) 역할을 합니다. 이 필드의 값이 "success"인지 "error"인지에 따라 타입이 자동으로 확정됩니다.
function handleResponse(response: ApiResponse) {
if (response.status === "success") {
console.log(response.data.name); // ✅ SuccessResponse로 확정
} else {
console.log(`에러 ${response.errorCode}: ${response.message}`); // ✅ ErrorResponse로 확정
}
}
switch문과 결합하면 더 깔끔하게 분기할 수 있습니다. 상태가 세 가지 이상으로 나뉘는 경우에 특히 유용합니다.
interface LoadingState {
status: "loading";
}
interface SuccessState {
status: "success";
data: string[];
}
interface ErrorState {
status: "error";
message: string;
}
type FetchState = LoadingState | SuccessState | ErrorState;
function renderUI(state: FetchState) {
switch (state.status) {
case "loading":
}
}
판별된 유니온의 장점은 코드의 의도가 명확하다는 것입니다. status 필드 하나만 보면 어떤 타입인지 바로 알 수 있기 때문에 가독성이 좋고, 새로운 상태가 추가되더라도 switch문에 case만 추가하면 되므로 확장도 용이합니다. 또한 never 타입을 활용한 exhaustive check를 함께 쓰면, 누락된 분기를 컴파일 단계에서 바로 잡아낼 수 있어 더 안전합니다. 리액트에서 API 호출 상태를 관리하거나, Redux 같은 상태 관리 도구에서 액션을 구분할 때도 이 패턴이 자주 사용됩니다.
지금까지 살펴본 패턴들은 타입스크립트가 조건문을 분석해서 자동으로 타입을 좁혀주는 경우였습니다. 하지만 타입스크립트가 자동으로 추론하지 못하는 상황도 있습니다. 예를 들어, 복잡한 조건을 별도 함수로 분리하면, 그 함수의 반환값이 boolean일 뿐 타입 좁히기로 연결되지 않습니다.
function isString(value: unknown): boolean {
return typeof value === "string";
}
function process(value: string | number) {
if (isString(value)) {
value.toUpperCase(); // ❌ 여전히 string | number
}
}
위 코드에서 isString 함수는 분명히 문자열 여부를 확인하고 있지만, 타입스크립트 입장에서는 그냥 boolean을 반환하는 함수일 뿐입니다. 조건문 안에서의 타입 좁히기가 작동하지 않습니다.
이런 상황에서 is 키워드를 사용하면 됩니다. 반환 타입을 value is string 형태로 선언하면, 이 함수가 true를 반환할 때 해당 매개변수가 특정 타입임을 타입스크립트에 알려줄 수 있습니다.
function isString(value: unknown): value is string {
return typeof value === "string";
}
function process(value: string | number) {
if (isString(value)) {
value.toUpperCase(); // ✅ string 타입으로 확정
}
}
이 패턴은 반복적으로 사용되는 타입 체크 로직을 함수로 분리할 때 특히 유용합니다. 여러 곳에서 동일한 타입 확인 조건을 작성하는 대신, 타입 가드 함수 하나를 만들어두면 코드의 중복을 줄이면서도 타입 안전성을 유지할 수 있습니다. 배열에서 null이나 undefined를 걸러내는 필터링 패턴에서도 자주 활용됩니다.
function isNotNull<T>(value: T | null): value is T {
return value !== null;
}
const items = ["사과", null, "바나나", null, "딸기"];
const filtered = items.filter(isNotNull);
// filtered의 타입: string[]
Array.filter에 일반적인 boolean 반환 함수를 넘기면 타입스크립트가 null이 제거되었다는 것을 대부분 인식하지 못해 (string | null)[] 타입이 유지되는데, is 키워드를 사용한 타입 가드를 넘기면 string[]으로 정확하게 추론됩니다.
타입 좁히기는 강력한 기능이지만, 실무에서 사용하다 보면 몇 가지 실수하기 쉬운 지점이 있습니다.
먼저 첫 번째로 주의할 것은 타입 단언(as)의 남용입니다. 타입 좁히기 대신 as로 강제 변환하면 코드가 짧아지는 것처럼 보이지만, 타입 안전성을 포기하는 것입니다. 실제 런타임 값이 해당 타입이 아닐 경우, 타입스크립트는 에러를 잡아주지 못합니다.
// ❌ 위험: 실제로 string이 아닐 수 있음
const value = someFunction() as string;
console.log(value.toUpperCase()); // 런타임 에러 가능
// ✅ 안전: 실제로 string인지 확인 후 사용
const value = someFunction();
if (typeof value === "string") {
console.log(value.toUpperCase());
}
두 번째는 콜백 함수 안에서 좁혀진 타입이 풀리는 경우입니다. 바깥 스코프에서 타입을 좁혀놨더라도, 콜백 안에서는 이 정보가 유지되지 않을 수 있습니다.
function process(value: string | null) {
if (value !== null) {
// 여기서는 string 타입으로 좁혀짐
setTimeout(() => {
console.log(value.toUpperCase()); // ✅ 이 경우는 동작함
}, 100);
}
}
위 예시처럼 재할당 가능성이 없고 제어 흐름상 안전하다고 판단되는 경우에는 콜백 안에서도 좁혀진 타입이 유지될 수 있습니다. 하지만 let으로 선언된 변수는 콜백이 실행되기 전에 다른 값이 할당될 수 있기 때문에, 타입스크립트의 제어 흐름 분석이 클로저 경계를 넘어서까지 안전하다고 보장하지 못하는 경우가 있습니다. 이런 상황에서는 좁혀진 값을 별도의 const 변수에 담아두는 방법이 안전합니다.
let value: string | null = getString();
if (value !== null) {
const confirmed = value; // string 타입으로 확정된 값을 별도 변수에 저장
setTimeout(() => {
console.log(confirmed.toUpperCase()); // ✅ 안전
}, 100);
}
이번 글에서는 타입스크립트의 타입 좁히기에 대해 살펴보았습니다. 코드를 작성하다 as를 사용하고 싶은 순간이 오면, 잠시 멈추고 타입 좁히기로 해결할 수 있는지 먼저 떠올려보세요. 대부분의 경우 조건문 한두 줄이면 충분하고, 그렇게 작성한 코드가 더 안전하고 읽기 쉬운 코드로 이어집니다. 타입 좁히기에 익숙해질수록 타입스크립트를 더 자신 있게 다룰 수 있게 될 것입니다.
©️요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.