function process(value: string | number) {
console.log(value.toUpperCase());
// ❌ Property 'toUpperCase' does not exist on type 'string | number'.
}
TypeScript를 처음 사용할 때 이 에러를 자주 만났습니다.
value가 string일 수도 있으니 toUpperCase()를 쓸 수 있어야 하는 것 아닌가? 하지만 TypeScript 입장에서는 value가 number일 가능성도 있습니다. number에는 toUpperCase()가 없으니 에러를 내는 거죠.
그런데 if문 하나를 추가하면 에러가 사라집니다.
function process(value: string | number) {
if (typeof value === 'string') {
console.log(value.toUpperCase()); // ✅ OK!
}
}
TypeScript가 if문 안에서는 value가 string이라는 걸 인식합니다. 별도의 타입 단언(as string) 없이도요.
이게 바로 Type Narrowing입니다. 이 개념을 이해하고 나면 TypeScript 코드 작성이 훨씬 수월해집니다.
Type Narrowing은 넓은 타입을 좁은 타입으로 좁혀가는 과정입니다.
string | number → string처럼, 가능한 타입의 범위를 줄여나가는 것이죠.
TypeScript의 핵심 가치는 컴파일 타임에 에러를 잡는 것입니다. 그런데 Union 타입을 사용하면 "이 값이 정확히 어떤 타입인지 모른다"는 상황이 생깁니다.
function formatValue(value: string | number) {
// value가 string인지 number인지 알 수 없으므로
// 어떤 메서드도 안전하게 호출할 수 없습니다
}
Type Narrowing은 이 문제를 해결합니다. "이 시점에서 이 값은 확실히 string이다"라고 TypeScript에게 알려주는 것이죠.
TypeScript는 코드의 흐름을 분석해서 타입을 자동으로 좁혀줍니다. 이 분석을 Control Flow Analysis(제어 흐름 분석)라고 부릅니다. (TypeScript 2.0부터 도입된 기능입니다.)
function greet(value: string | number) {
// 여기서 value는 string | number
if (typeof value === 'string') {
// 이 블록에서 value는 string
console.log(value.toUpperCase());
} else {
// 이 블록에서 value는 number
console.log(value.toFixed(2));
}
}
그리고 이런 분석을 가능하게 하는 조건문들을 Type Guard라고 합니다.
그럼 이제부터는 Type Guard를 실제 상황에서 어떻게 사용할 수 있을지 정리해보겠습니다.
원시 타입을 구분하는 가장 기본적인 방법입니다. 개인적으로 가장 자주 쓰는 Type Guard이기도 합니다.
function formatValue(value: string | number | boolean) {
if (typeof value === 'string') {
return value.trim(); // string
}
if (typeof value === 'number') {
return value.toFixed(2); // number
}
return value ? 'Yes' : 'No'; // boolean
}
TypeScript는 typeof 연산자의 결과를 신뢰합니다. JavaScript 런타임에서 typeof가 반환하는 값은 확실하기 때문입니다. typeof value === 'string'이 true라면, 그 시점에서 value는 100% string입니다.
typeof가 반환하는 값들: "string", "number", "boolean", "undefined", "object", "function", "symbol", "bigint"
API 응답을 처리할 때 특히 유용합니다. 서버에서 숫자가 문자열로 올 때도 있고, 숫자 그대로 올 때도 있거든요.
function parseAmount(value: string | number): number {
if (typeof value === 'string') {
return parseFloat(value);
}
return value;
}
주의할 점: typeof null은 "object"를 반환합니다. JavaScript의 오래된 버그입니다. 따라서 null 체크는 typeof로 하면 안 됩니다.
null이나 undefined를 걸러내는 가장 간단한 방법입니다. 코드도 짧고 직관적이라 자주 쓰게 됩니다.
function greet(name: string | null | undefined) {
if (name) {
console.log(`Hello, ${name}!`); // string
}
}
JavaScript에서 null과 undefined는 falsy 값입니다. 따라서 if (value)를 통과했다면, value는 null도 undefined도 아닌 것이 확실합니다. TypeScript는 이 JavaScript 동작을 알고 있어서 타입을 자동으로 좁혀줍니다.
아래는 React에서 Optional props를 처리할 때 자주 사용하는 패턴입니다.
function UserCard({ user }: { user?: User }) {
if (user) {
return <div>{user.name}</div>;
}
return <div>Loading...</div>;
}
배열에도 동일하게 적용됩니다.
function getLength(arr?: string[]) {
if (arr) {
return arr.length; // string[]
}
return 0;
}
그런데 여기서 함정이 하나 있습니다. 0, "", NaN도 falsy 값이라서 의도치 않게 걸러질 수 있습니다.
function goToPage(page: number | null) {
if (page) {
// ⚠️ page가 0이면 이 블록에 진입하지 않습니다
navigate(`/list?page=${page}`);
}
// 명시적인 null 체크가 더 안전합니다
if (page !== null) {
// page가 0이어도 진입합니다
navigate(`/list?page=${page}`);
}
}
실제로 페이지네이션에서 첫 페이지(0)로 이동이 안 되는 버그를 만난 적이 있습니다. if (page) 조건 때문이었습니다. 그 이후로 숫자를 다룰 때는 !== null을 명시적으로 사용합니다.
Truthiness 체크는 "falsy가 아닌 모든 것"을 통과시킵니다. 반면 동등 비교는 정확히 그 값인지 확인합니다. null만 걸러내고 싶을 때, 0이나 ""는 살리고 싶을 때 동등 비교가 필요합니다.
function handle(value: string | null) {
if (value === null) {
return 'No value';
}
return value.toUpperCase(); // string
}
리터럴 타입과 함께 사용하면 더 강력해집니다.
type Status = 'loading' | 'success' | 'error';
function getMessage(status: Status) {
if (status === 'loading') {
return 'Loading...'; // 'loading'
}
if (status === 'success') {
return 'Done!'; // 'success'
}
return 'Something went wrong'; // 'error'
}
API 호출 상태를 관리할 때 유용한 패턴입니다.
객체에 특정 프로퍼티가 있는지로 타입을 구분합니다.
interface AdminUser {
role: 'admin';
permissions: string[];
}
interface GuestUser {
role: 'guest';
expiresAt: Date;
}
function getUserInfo(user: AdminUser | GuestUser) {
if ('permissions' in user) {
console.log(`권한: ${user.permissions.join(', ')}`); // AdminUser
} else {
console.log(`만료일: ${user.expiresAt}`); // GuestUser
}
}
API 응답 처리에서 성공/실패 응답의 구조가 다를 때 유용합니다.
interface SuccessResponse { data: User; }
interface ErrorResponse { error: string; }
function handleResponse(res: SuccessResponse | ErrorResponse) {
if ('error' in res) {
console.error(res.error); // ErrorResponse
return;
}
console.log(res.data); // SuccessResponse
}
res.error로 바로 접근하면 안 되는 이유가 있습니다. SuccessResponse에는 error 프로퍼티가 정의되어 있지 않기 때문에 접근 자체가 타입 에러가 됩니다. in 연산자는 런타임에 프로퍼티 존재 여부를 체크하면서 TypeScript에게 타입 정보도 전달합니다.
클래스로 만든 객체를 구분할 때 씁니다.
class ApiError extends Error {
statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.statusCode = statusCode;
}
}
function handleError(error: Error) {
if (error instanceof ApiError) {
console.log(`Status: ${error.statusCode}`); // ApiError
} else {
console.log(error.message); // Error
}
}
instanceof는 객체의 프로토타입 체인을 확인합니다. error instanceof ApiError가 true라면, error는 ApiError 클래스(또는 그 하위 클래스)의 인스턴스입니다. TypeScript는 이를 통해 해당 클래스의 프로퍼티와 메서드에 안전하게 접근할 수 있다고 판단합니다.
커스텀 에러 클래스를 만들어서 에러 종류별로 다르게 처리할 수 있습니다.
class ValidationError extends Error {
field: string;
constructor(field: string, message: string) {
super(message);
this.field = field;
}
}
class NetworkError extends Error {
retryable: boolean;
constructor(message: string, retryable: boolean) {
super(message);
this.retryable = retryable;
}
}
function handleError(error: Error) {
if (error instanceof ValidationError) {
showFieldError(error.field, error.message);
} else if (error instanceof NetworkError && error.retryable) {
showRetryButton();
} else {
showGenericError(error.message);
}
}
typeof []는 "object"를 반환합니다. 그래서 배열인지 확인하려면 Array.isArray를 써야 합니다.
function process(input: string | string[]) {
if (Array.isArray(input)) {
return input.join(', '); // string[]
}
return input; // string
}
API 응답이 단일 객체일 수도 있고 배열일 수도 있을 때 유용합니다.
function normalizeResponse(data: User | User[]) {
if (Array.isArray(data)) {
return data; // User[]
}
return [data]; // User -> User[]
}
이렇게 정규화하면 이후 코드에서 항상 배열로 일관되게 처리할 수 있습니다.
복잡한 상태를 다룰 때 가장 권장하는 패턴입니다. 공통된 리터럴 프로퍼티(discriminant)로 타입을 구분합니다.
type ApiState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User }
| { status: 'error'; error: string };
function render(state: ApiState) {
switch (state.status) {
case 'idle':
return <div>Ready</div>;
case 'loading':
return <Spinner />;
case 'success':
return <UserCard user={state.data} />; // data 접근 가능
case 'error':
return <ErrorMessage message={state.error} />; // error 접근 가능
}
}
이 패턴의 핵심 장점은 불가능한 상태 조합을 타입 레벨에서 방지한다는 것입니다.
아래와 같은 설계를 비교해보면 차이가 명확합니다.
// ❌ 문제가 있는 설계: 불가능한 상태 조합이 허용됨
interface State {
isLoading: boolean;
data: User | null;
error: string | null;
}
// isLoading: true이면서 data와 error가 모두 존재하는 상태가 가능
// 이런 상태는 논리적으로 말이 안 됩니다
Discriminated Union을 사용하면 이런 논리적 오류를 컴파일 타임에 방지할 수 있습니다.
Redux나 Zustand의 액션 타입에서도 이 패턴이 널리 사용됩니다.
type Action =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'SET'; payload: number };
function reducer(state: number, action: Action) {
switch (action.type) {
case 'INCREMENT': return state + 1;
case 'DECREMENT': return state - 1;
case 'SET': return action.payload; // payload 접근 가능
}
}
기본 Type Guard들로 해결이 안 되는 경우가 있습니다. 복잡한 조건이 필요하거나, 조건을 재사용하고 싶을 때입니다.
이럴 때 is 키워드로 직접 Type Guard를 정의할 수 있습니다.
function isAdmin(user: AdminUser | GuestUser): user is AdminUser {
return 'permissions' in user;
}
반환 타입이 user is AdminUser인 것이 핵심입니다. "이 함수가 true를 반환하면 user는 AdminUser 타입이다"라고 TypeScript에게 알려주는 것이죠.
단순히 boolean을 반환하는 것과의 차이를 살펴보겠습니다.
// ❌ boolean 반환: 타입이 좁혀지지 않음
function isAdminBoolean(user: AdminUser | GuestUser): boolean {
return 'permissions' in user;
}
if (isAdminBoolean(user)) {
console.log(user.permissions); // 에러: user는 여전히 AdminUser | GuestUser
}
// ✅ is 키워드 사용: 타입이 좁혀짐
function isAdmin(user: AdminUser | GuestUser): user is AdminUser {
return 'permissions' in user;
}
if (isAdmin(user)) {
console.log(user.permissions); // OK: user는 AdminUser
}
interface SuccessResponse {
success: true;
data: User;
}
interface ErrorResponse {
success: false;
error: string;
}
type ApiResponse = SuccessResponse | ErrorResponse;
// 사용자 정의 Type Guard
function isSuccess(res: ApiResponse): res is SuccessResponse {
return res.success === true;
}
// 사용
function handleResponse(res: ApiResponse) {
if (isSuccess(res)) {
console.log(res.data); // SuccessResponse
} else {
console.error(res.error); // ErrorResponse
}
}
이렇게 정의하면 여러 곳에서 isSuccess 함수를 재사용할 수 있습니다.
Type Guard는 filter와 함께 사용할 때 특히 유용합니다.
const items: (string | null)[] = ['a', null, 'b', null, 'c'];
// ❌ 일반 filter: 타입이 (string | null)[]로 유지됨
const filtered1 = items.filter(item => item !== null);
// filtered1의 타입: (string | null)[]
// ✅ Type Guard 사용: 타입이 string[]로 좁혀짐
function isNotNull<T>(value: T | null): value is T {
return value !== null;
}
const filtered2 = items.filter(isNotNull);
// filtered2의 타입: string[]
이 차이가 중요한 이유는, filtered1을 사용할 때마다 null 체크를 다시 해야 하기 때문입니다. filtered2는 그럴 필요가 없습니다.
실무에서는 이런 식으로 활용합니다.
// 비어있지 않은 문자열만 필터링
function isNonEmptyString(value: string | null | undefined): value is string {
return value !== null && value !== undefined && value.length > 0;
}
const tags = ['react', '', null, 'typescript', undefined];
const validTags = tags.filter(isNonEmptyString); // string[]
상황별로 정리하면 다음과 같습니다.
| 상황 | Type Guard |
|---|---|
| 원시 타입(string, number 등) 구분 | typeof |
| null/undefined 체크 | Truthiness 또는 === null |
| 숫자/문자열의 null 체크 | !== null (0, ""을 살리려면) |
| 특정 값 체크 | === 동등 비교 |
| 객체의 프로퍼티로 구분 | in 연산자 |
| 클래스 인스턴스 구분 | instanceof |
| 배열 여부 확인 | Array.isArray |
| 복잡한 상태 관리 | Discriminated Union |
| 재사용 가능한 조건 | 사용자 정의 Type Guard (is) |
정리하면 다음과 같습니다.
as) 없이도 안전한 코드를 작성할 수 있습니다Type Narrowing을 제대로 활용하면서 as 키워드 사용이 크게 줄었습니다. 이전에는 "TypeScript가 왜 이걸 모르지?"라며 as를 자주 사용했는데, 이제는 "내가 TypeScript에게 충분한 정보를 제공하지 않았구나"라고 생각하게 되었습니다.
그런데 한 가지 의문이 남습니다.
if (state.user) {
setTimeout(() => {
console.log(state.user.name); // ❌ 에러?!
}, 100);
}
분명 if문에서 체크했는데, 왜 콜백 안에서는 에러가 발생할까요?
이 질문에 대한 답은 다음 글에서 다루겠습니다.
다음 글: TypeScript는 왜 내 코드를 의심할까