본문으로 건너뛰기
Advertisement

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 = 42number
const 리터럴 추론리터럴 타입으로 고정const x = 4242
함수 반환 추론반환 표현식 분석return n * 2number
문맥적 타이핑왼쪽(문맥) → 오른쪽(값) 방향이벤트 핸들러 콜백
최적 공통 타입여러 타입의 유니온[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 타입 시스템의 핵심 구조를 살펴본다.

Advertisement