2.5 타입 추론
TypeScript의 타입 추론(Type Inference)은 컴파일러가 코드의 문맥을 분석해 개발자가 명시하지 않은 타입을 스스로 결정하는 능력이다. 잘 동작하는 추론 시스템 덕분에 모든 곳에 타입 어노테이션을 작성하지 않아도 타입 안전성을 유지할 수 있다. 반대로 추론이 의도와 다르게 작동하는 경우를 이해하면 불필요한 타입 오류를 피할 수 있다.
이 장에서는 기본 추론부터 시작해 문맥적 타이핑, 최적 공통 타입, 타입 넓히기와 좁히기, as const를 통한 리터럴 타입 고정, 그리고 언제 어노테이션이 필요한지까지 체계적으로 살펴본다.
기본 타입 추론 — 변수 초기화
변수 선언과 동시에 초기값을 할당하면 TypeScript는 그 값의 타입을 변수 타입으로 자동 결정한다.
const greeting = "Hello, TypeScript"; // string
const count = 42; // number
const pi = 3.14159; // number
const isReady = true; // boolean
const nothing = null; // null
const missing = undefined; // undefined
// 이후 잘못된 타입 할당 시 오류
let message = "initial";
message = 123; // 오류: Type 'number' is not assignable to type 'string'
let vs const의 추론 차이
const로 선언된 변수는 재할당이 불가능하므로 리터럴 타입으로 추론된다. let은 나중에 다른 값으로 바뀔 수 있으므로 더 넓은 원시 타입으로 추론된다.
const constStr = "hello"; // 타입: "hello" (리터럴 타입)
let letStr = "hello"; // 타입: string (넓힌 타입)
const constNum = 42; // 타입: 42 (리터럴 타입)
let letNum = 42; // 타입: number (넓힌 타입)
const constBool = true; // 타입: true (리터럴 타입)
let letBool = true; // 타입: boolean (넓힌 타입)
이 차이는 리터럴 타입이 필요한 상황에서 중요하다.
type Direction = "north" | "south" | "east" | "west";
const dir1 = "north"; // 타입: "north" — Direction에 할당 가능
let dir2 = "north"; // 타입: string — Direction에 직접 할당 불가
function move(d: Direction): void { /* ... */ }
move(dir1); // OK
move(dir2); // 오류: Argument of type 'string' is not assignable to parameter of type 'Direction'
함수 반환값 추론
함수 본문을 분석해 반환 타입을 자동으로 결정한다.
// 반환 타입 어노테이션 없음 — string으로 추론
function getGreeting(name: string) {
return `Hello, ${name}!`;
}
// 추론: (name: string) => string
// 조건부 반환 — 유니온 타입으로 추론
function divide(a: number, b: number) {
if (b === 0) return null;
return a / b;
}
// 추론: (a: number, b: number) => number | null
// 여러 반환 경로 — 모든 경로의 타입 유니온
function classify(n: number) {
if (n > 0) return "positive";
if (n < 0) return "negative";
return "zero";
}
// 추론: (n: number) => "positive" | "negative" | "zero"
문맥적 타이핑 (Contextual Typing)
타입 추론은 주로 오른쪽(값)에서 왼쪽(변수)으로 흐른다. 그러나 문맥적 타이핑은 반대 방향으로, 왼쪽의 타입 정보가 오른쪽 표현식의 타입을 결정한다.
이벤트 핸들러
// window.onmousedown의 타입이 이미 정의되어 있으므로
// 콜백 매개변수 mouseEvent의 타입을 별도로 쓸 필요 없다
window.onmousedown = function (mouseEvent) {
// mouseEvent는 MouseEvent로 문맥적으로 타이핑됨
console.log(mouseEvent.button); // OK — MouseEvent에 button이 존재
console.log(mouseEvent.kangaroo); // 오류: Property 'kangaroo' does not exist on type 'MouseEvent'
};
// 문맥 없이 단독 함수로 선언하면 매개변수가 any로 추론됨
const handler = function (mouseEvent) {
// noImplicitAny 옵션 시 오류: Parameter 'mouseEvent' implicitly has an 'any' type
};
콜백 매개변수
const numbers = [1, 2, 3, 4, 5];
// forEach 콜백에서 value의 타입을 number로 문맥 추론
numbers.forEach((value) => {
console.log(value.toFixed(2)); // OK — value는 number
});
// map 콜백 — value: number, index: number, array: number[]
const doubled = numbers.map((value) => value * 2);
// doubled: number[]
객체 타입 내 메서드
interface EventHandlers {
onClick: (event: MouseEvent) => void;
onKeyDown: (event: KeyboardEvent) => void;
}
const handlers: EventHandlers = {
// event는 MouseEvent로 문맥적 타이핑됨
onClick(event) {
console.log(event.clientX, event.clientY);
},
// event는 KeyboardEvent로 문맥적 타이핑됨
onKeyDown(event) {
console.log(event.key, event.code);
},
};
최적 공통 타입 (Best Common Type)
여러 값으로 구성된 배열 리터럴이나 조건부 반환문처럼 여러 타입이 공존하는 표현식에서, TypeScript는 모든 값을 수용할 수 있는 **최적 공통 타입(Best Common Type)**을 계산한다.
// 숫자와 null — (number | null)[]
const arr1 = [1, 2, null];
// 타입: (number | null)[]
// 문자열과 숫자 — (string | number)[]
const arr2 = ["hello", 42, "world"];
// 타입: (string | number)[]
// 클래스 계층 — 공통 부모 타입으로 추론
class Animal { name: string = ""; }
class Dog extends Animal { breed: string = ""; }
class Cat extends Animal { indoor: boolean = true; }
const pets = [new Dog(), new Cat()];
// 타입: (Dog | Cat)[] — Animal이 아닌 유니온으로 추론된다!
// TypeScript는 선언된 타입 중에서 최적 공통 타입을 찾지, 부모 클래스를 자동으로 선택하지 않는다
명시적 타입으로 공통 타입 강제
컴파일러가 추론한 것보다 더 넓은 타입이 필요하다면 명시적 어노테이션을 사용한다.
class Animal { move(): void { console.log("move"); } }
class Dog extends Animal { bark(): void { console.log("woof"); } }
class Cat extends Animal { meow(): void { console.log("meow"); } }
// 명시적 어노테이션으로 Animal[] 강제
const animals: Animal[] = [new Dog(), new Cat()];
animals[0].move(); // OK
// animals[0].bark(); // 오류: Property 'bark' does not exist on type 'Animal'
// 타입 단언을 피하고 제네릭을 이용한 타입 강제
function createArray<T>(...items: T[]): T[] {
return items;
}
const dogs = createArray(new Dog(), new Dog()); // Dog[]
타입 넓히기 (Widening)
let으로 선언한 변수에 리터럴 값을 할당하면 TypeScript는 나중에 다른 값이 할당될 수 있다고 판단해 리터럴 타입을 해당 원시 타입으로 넓혀서(Widen) 추론한다.
let x = "hello"; // 넓혀서 string으로 추론 (not "hello")
let n = 42; // 넓혀서 number로 추론 (not 42)
let b = true; // 넓혀서 boolean으로 추론 (not true)
x = "world"; // OK — string이므로 허용
n = 99; // OK — number이므로 허용
null 넓히기
null이나 undefined로 초기화한 변수는 타입을 명시하지 않으면 null 또는 undefined 타입으로 추론된다. 이후 다른 타입 값을 할당하려면 유니온 타입 어노테이션이 필요하다.
let value = null; // 타입: null
value = "hello"; // 오류: Type 'string' is not assignable to type 'null'
// 유니온 타입으로 명시해야 한다
let flexible: string | null = null;
flexible = "hello"; // OK
넓히기가 문제가 되는 상황
type Status = "active" | "inactive" | "pending";
// 의도: Status 타입 변수로 쓰고 싶다
let status = "active"; // 추론: string (넓혀짐)
function setStatus(s: Status): void { /* ... */ }
setStatus(status); // 오류: Argument of type 'string' is not assignable to parameter of type 'Status'
// 해결책 1: 타입 어노테이션
let status2: Status = "active"; // Status 타입으로 고정
setStatus(status2); // OK
// 해결책 2: as const
const status3 = "active" as const; // 타입: "active"
setStatus(status3); // OK
타입 좁히기 (Narrowing)
유니온 타입이나 unknown 타입 변수를 조건문, 타입 가드, 특정 연산자로 검사하면 해당 블록 내에서 변수의 타입이 더 구체적으로 좁혀진다(Narrow).
typeof 가드
function processValue(value: string | number | boolean): string {
if (typeof value === "string") {
// 이 블록에서 value: string
return value.toUpperCase();
}
if (typeof value === "number") {
// 이 블록에서 value: number
return value.toFixed(2);
}
// 이 블록에서 value: boolean (string과 number를 제외한 나머지)
return value ? "참" : "거짓";
}
instanceof 가드
function formatDate(value: Date | string): string {
if (value instanceof Date) {
// 이 블록에서 value: Date
return value.toLocaleDateString("ko-KR");
}
// 이 블록에서 value: string
return new Date(value).toLocaleDateString("ko-KR");
}
in 연산자 가드
객체의 특정 프로퍼티 존재 여부로 타입을 좁힌다.
interface Circle {
kind: "circle";
radius: number;
}
interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}
type Shape = Circle | Rectangle;
function getArea(shape: Shape): number {
if ("radius" in shape) {
// 이 블록에서 shape: Circle
return Math.PI * shape.radius ** 2;
}
// 이 블록에서 shape: Rectangle
return shape.width * shape.height;
}
판별 프로퍼티 (Discriminant Property) 가드
유니온 타입에 공통의 리터럴 타입 프로퍼티(kind, type, tag 등)가 있으면 그 값으로 타입을 좁힐 수 있다. 이를 **판별 유니온(Discriminated Union)**이라 한다.
type Result<T> =
| { status: "success"; data: T }
| { status: "error"; code: number; message: string }
| { status: "loading" };
function handleResult<T>(result: Result<T>): void {
switch (result.status) {
case "success":
// result: { status: "success"; data: T }
console.log("데이터:", result.data);
break;
case "error":
// result: { status: "error"; code: number; message: string }
console.error(`${result.code}: ${result.message}`);
break;
case "loading":
// result: { status: "loading" }
console.log("로딩 중...");
break;
}
}
등호와 null 좁히기
function greet(name: string | null | undefined): string {
if (name == null) {
// == null은 null과 undefined 둘 다 걸러낸다
// 이 블록에서 name: null | undefined
return "안녕하세요, 손님!";
}
// 이 블록에서 name: string
return `안녕하세요, ${name}님!`;
}
사용자 정의 타입 가드 (Type Predicate)
복잡한 구조 검사를 재사용 가능한 함수로 만든다.
interface ApiUser {
id: number;
username: string;
email: string;
}
function isApiUser(value: unknown): value is ApiUser {
return (
typeof value === "object" &&
value !== null &&
typeof (value as ApiUser).id === "number" &&
typeof (value as ApiUser).username === "string" &&
typeof (value as ApiUser).email === "string"
);
}
function processApiResponse(raw: unknown): string {
if (isApiUser(raw)) {
// 이 블록에서 raw: ApiUser
return `${raw.username} (${raw.email})`;
}
return "유효하지 않은 응답";
}
as const — 리터럴 타입 고정
as const는 표현식의 타입을 가능한 한 좁은(narrow) 리터럴 타입으로 고정한다. const 선언과 달리 객체나 배열의 중첩 모든 값에 적용된다.
// 객체에 as const
const config = {
host: "localhost",
port: 3000,
ssl: false,
} as const;
// config의 타입:
// {
// readonly host: "localhost";
// readonly port: 3000;
// readonly ssl: false;
// }
config.port = 8080; // 오류: Cannot assign to 'port' because it is a read-only property
// as const 없이
const configMutable = {
host: "localhost",
port: 3000,
};
// configMutable의 타입: { host: string; port: number }
배열과 튜플에서 as const
// 배열 → readonly 튜플로 변환
const ALLOWED_METHODS = ["GET", "POST", "PUT", "DELETE"] as const;
// 타입: readonly ["GET", "POST", "PUT", "DELETE"]
type AllowedMethod = typeof ALLOWED_METHODS[number];
// "GET" | "POST" | "PUT" | "DELETE"
// 함수 반환을 튜플로 강제
function getCoord() {
return [10, 20] as const;
}
// 반환 타입: readonly [10, 20]
const [x, y] = getCoord();
// x: 10, y: 20 (number가 아닌 리터럴 타입)
as const와 중첩 객체
as const는 깊이 전파된다. 중첩된 모든 프로퍼티가 readonly 리터럴 타입이 된다.
const ROUTES = {
home: "/",
users: {
list: "/users",
create: "/users/new",
detail: (id: number) => `/users/${id}` as const,
},
posts: {
list: "/posts",
detail: "/posts/:id",
},
} as const;
type HomeRoute = typeof ROUTES.home; // "/"
type UserListRoute = typeof ROUTES.users.list; // "/users"
// 모든 라우트 값 유니온 타입
type StaticRoute = typeof ROUTES.home | typeof ROUTES.users.list | typeof ROUTES.users.create
| typeof ROUTES.posts.list | typeof ROUTES.posts.detail;
// "/" | "/users" | "/users/new" | "/posts" | "/posts/:id"
타입 명시 vs 추론 가이드라인
추론에 맡겨도 될 때와 명시적 어노테이션이 필요한 때를 구분하는 기준이다.
추론에 맡겨도 되는 경우
// 1. 초기값이 있는 변수
const name = "Alice"; // string 추론
const items = [1, 2, 3]; // number[] 추론
// 2. 명백한 반환 타입의 함수
function double(n: number) {
return n * 2; // number 반환 추론
}
// 3. forEach, map, filter 등 콜백
const names = ["Alice", "Bob"];
names.forEach((name) => console.log(name.toUpperCase())); // name: string 추론
어노테이션이 필요한 경우
// 1. 나중에 할당되는 변수
let result: string;
if (Math.random() > 0.5) {
result = "heads";
} else {
result = "tails";
}
// 2. 함수 매개변수 — 항상 명시
function process(data: unknown): void { /* ... */ }
// 3. 반환 타입이 복잡하거나 의도를 명확히 할 때
function parseUser(raw: unknown): ApiUser | null {
// 반환 타입을 명시하면 함수 계약이 명확해진다
if (!isApiUser(raw)) return null;
return raw;
}
// 4. 추론이 의도와 다를 때
const status: Status = "active"; // string이 아닌 Status 리터럴 타입 필요
// 5. 빈 배열 초기화
const tags: string[] = []; // [] 만으로는 never[]로 추론됨
tags.push("typescript"); // OK
// 6. 객체 타입이 복잡할 때 명시적 인터페이스 사용
const user: UserProfile = {
id: 1,
name: "Alice",
email: "alice@example.com",
};
실전 예제 1: API 응답 타입 추론
// Fetch wrapper — 반환 타입을 제네릭으로 위임
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json() as Promise<T>;
}
// 사용: 반환 타입이 자동으로 추론됨
interface Post {
id: number;
title: string;
body: string;
userId: number;
}
async function getPost(id: number): Promise<Post> {
// Post 타입으로 추론됨
return fetchJson<Post>(`https://jsonplaceholder.typicode.com/posts/${id}`);
}
async function main() {
const post = await getPost(1);
// post.title: string — 타입 안전하게 접근
console.log(post.title.toUpperCase());
}
// 여러 타입 리소스를 다루는 제네릭 리포지토리
class Repository<T extends { id: number }> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
findById(id: number): T | undefined {
// item은 T로 추론됨
return this.items.find((item) => item.id === id);
}
getAll(): readonly T[] {
return this.items;
}
}
const postRepo = new Repository<Post>();
postRepo.add({ id: 1, title: "Hello", body: "World", userId: 1 });
const found = postRepo.findById(1); // Post | undefined
실전 예제 2: 설정 객체 as const 패턴
// 앱 전체 설정을 as const로 불변 관리
const APP_CONFIG = {
api: {
baseUrl: "https://api.example.com",
version: "v2",
timeout: 5000,
},
auth: {
tokenKey: "auth_token",
refreshKey: "refresh_token",
expiryMinutes: 60,
},
features: {
darkMode: true,
analytics: false,
betaFeatures: false,
},
supportedLocales: ["ko", "en", "ja", "zh"] as const,
} as const;
// 타입 추출
type AppConfig = typeof APP_CONFIG;
type ApiConfig = typeof APP_CONFIG.api;
type SupportedLocale = typeof APP_CONFIG.supportedLocales[number];
// "ko" | "en" | "ja" | "zh"
function setLocale(locale: SupportedLocale): void {
console.log(`언어 변경: ${locale}`);
}
setLocale("ko"); // OK
setLocale("fr"); // 오류: Argument of type '"fr"' is not assignable
// 설정 값에 안전하게 접근
function getApiUrl(path: string): string {
return `${APP_CONFIG.api.baseUrl}/${APP_CONFIG.api.version}/${path}`;
}
// 기능 플래그 확인
function isFeatureEnabled(
feature: keyof typeof APP_CONFIG.features
): boolean {
return APP_CONFIG.features[feature];
}
console.log(isFeatureEnabled("darkMode")); // true
console.log(isFeatureEnabled("analytics")); // false
// console.log(isFeatureEnabled("unknown")); // 오류: 유효한 키가 아님
고수 팁
팁 1: satisfies 연산자로 추론과 검증을 동시에 (TypeScript 4.9+)
type Palette = {
red: string | [number, number, number];
green: string | [number, number, number];
blue: string | [number, number, number];
};
// as const만 사용하면 타입 검증이 안 된다
// satisfies는 타입을 검증하면서도 리터럴 타입 추론을 보존한다
const palette = {
red: [255, 0, 0],
green: "#00ff00",
blue: [0, 0, 255],
} satisfies Palette;
// palette.red는 [number, number, number]로 추론됨 (string | [...] 유니온이 아님)
console.log(palette.red[0]); // OK: 255
console.log(palette.green.toUpperCase()); // OK: "#00FF00"
// 잘못된 값은 오류
const wrong = {
red: [255, 0, 0],
green: "#00ff00",
// blue: 300, // 오류: number는 string | [number, number, number]에 할당 불가
} satisfies Palette;
팁 2: infer로 조건부 타입에서 추론 추출
// 함수 반환 타입 추출
type ReturnType<T extends (...args: unknown[]) => unknown> =
T extends (...args: unknown[]) => infer R ? R : never;
type DoubleReturn = ReturnType<typeof double>; // number
// 프로미스 내부 타입 추출
type Awaited<T> = T extends Promise<infer U> ? U : T;
type PostData = Awaited<ReturnType<typeof getPost>>; // Post
팁 3: 추론된 타입을 typeof로 재활용하라
// 긴 타입 정의를 직접 작성하는 대신 추론된 타입을 활용
function createDefaultUser() {
return {
id: 0,
name: "",
email: "",
role: "guest" as const,
createdAt: new Date(),
};
}
// 함수 반환 타입을 새 타입 별칭으로 활용
type DefaultUser = ReturnType<typeof createDefaultUser>;
팁 4: 좁히기가 예상대로 동작하지 않을 때 확인할 사항
// 문제: 프로퍼티 접근 후 타입이 바뀌어 버리는 경우
function processUser(user: { name: string | null }) {
if (user.name !== null) {
// 이 시점에서 user.name은 string으로 좁혀졌다고 생각하지만...
setTimeout(() => {
// 콜백 내부에서는 user.name이 다시 string | null이 될 수 있다
console.log(user.name?.toUpperCase()); // 옵셔널 체이닝 필요
}, 100);
}
}
// 해결책: 좁혀진 값을 지역 변수에 저장
function processUserSafe(user: { name: string | null }) {
const name = user.name; // 로컬 변수로 복사
if (name !== null) {
setTimeout(() => {
console.log(name.toUpperCase()); // OK — name은 string으로 고정됨
}, 100);
}
}
팁 5: 추론 실패 시 명시적 타입 어노테이션을 투자로 생각하라
// 복잡한 제네릭 체인에서 추론이 실패하면 중간 단계에 명시적 타입을 추가한다
const data = fetchAndTransform(); // 추론: SomeComplexType<...>
// 의도를 명확히 하는 타입 어노테이션 — 미래 독자(본인 포함)를 위한 투자
const typedData: ProcessedRecord[] = fetchAndTransform();
declare function fetchAndTransform(): ProcessedRecord[];
interface ProcessedRecord { id: number; value: string; }
정리 표
| 추론 종류 | 동작 방식 | 예시 |
|---|---|---|
| 변수 초기화 추론 | 초기값에서 타입 결정 | let x = 42 → number |
| const 리터럴 추론 | 리터럴 타입으로 고정 | const x = 42 → 42 |
| 함수 반환 추론 | 반환 표현식 분석 | return n * 2 → number |
| 문맥적 타이핑 | 왼쪽(문맥) → 오른쪽(값) 방향 | 이벤트 핸들러 콜백 |
| 최적 공통 타입 | 여러 타입의 유니온 | [1, "a"] → (number | string)[] |
| 타입 넓히기 | 리터럴 → 원시 타입 | let x = "hi" → string |
| 타입 좁히기 | 조건문으로 범위 축소 | typeof x === "string" |
| as const 고정 | 모든 값을 리터럴 타입으로 | {a: 1} as const → {readonly a: 1} |
| satisfies | 검증 + 리터럴 보존 | TS 4.9+ 권장 패턴 |
| 어노테이션 필요 여부 | 상황 |
|---|---|
| 불필요 | 초기값 있는 변수, 간단한 함수 반환, 콜백 매개변수 |
| 필요 | 함수 매개변수, 나중에 할당되는 변수, 빈 배열, 복잡한 반환 타입 |
| 권장 | 공개 API 함수 반환 타입, 복잡한 객체 타입 |
다음 장에서는 인터페이스(Interface)를 다룬다. 객체 타입을 정의하고, 선택적 프로퍼티, 읽기 전용 프로퍼티, 인덱스 시그니처, 함수 타입 인터페이스, 그리고 인터페이스 병합(declaration merging)까지 TypeScript 타입 시스템의 핵심 구조를 살펴본다.