TypeScript의 타입 추론을 정밀하게 제어하는 두 가지 핵심 도구인 const 타입 파라미터와 satisfies 연산자의 원리, 차이점, 실전 활용 패턴을 다룹니다.
1장에서 TypeScript 5.x의 전체 변경사항을 조망했습니다. 이 장에서는 타입 추론을 정밀하게 제어하는 두 가지 핵심 도구인 const 타입 파라미터와 satisfies 연산자를 깊이 있게 살펴봅니다. 이 두 기능은 "타입을 직접 쓰지 않으면서도 의도한 타입을 정확히 얻는" TypeScript의 핵심 철학을 구현합니다.
TypeScript는 제네릭 함수에 값을 전달할 때, 일반적으로 넓은 타입(wider type) 으로 추론합니다.
function createConfig<T extends Record<string, unknown>>(config: T): T {
return config;
}
const config = createConfig({
port: 3000,
host: "localhost",
debug: true,
});
// config의 타입: { port: number; host: string; debug: boolean }
// 우리가 원하는 타입: { port: 3000; host: "localhost"; debug: true }3000이 number로, "localhost"가 string으로 넓혀지는 것을 타입 확장(Type Widening) 이라고 합니다. 이를 방지하려면 호출 측에서 as const를 붙여야 했습니다.
// 5.0 이전: 호출 측에서 as const 필요
const config = createConfig({
port: 3000,
host: "localhost",
debug: true,
} as const);이 방법은 동작하지만, 함수를 사용하는 모든 곳에서 as const를 기억해야 한다는 부담이 있습니다.
TypeScript 5.0에서 도입된 const 타입 파라미터는 이 문제를 함수 선언 측에서 해결합니다.
function createConfig<const T extends Record<string, unknown>>(config: T): T {
return config;
}
const config = createConfig({
port: 3000,
host: "localhost",
debug: true,
});
// config의 타입: { readonly port: 3000; readonly host: "localhost"; readonly debug: true }<const T>를 사용하면, T에 전달되는 값이 자동으로 가장 좁은 리터럴 타입으로 추론됩니다. 호출 측에서는 as const를 쓸 필요가 없습니다.
const 수정자는 다음 세 가지 규칙으로 동작합니다.
readonly가 됩니다readonly 튜플이 됩니다function inspect<const T>(value: T): T {
return value;
}
// 1. 리터럴 보존
const a = inspect("hello"); // "hello" (string이 아님)
const b = inspect(42); // 42 (number가 아님)
// 2. 객체 readonly
const c = inspect({ x: 1, y: 2 });
// { readonly x: 1; readonly y: 2 }
// 3. 배열 → readonly 튜플
const d = inspect([1, "two", true]);
// readonly [1, "two", true]웹 프레임워크에서 라우트를 타입 안전하게 정의하는 패턴은 const 타입 파라미터의 가장 대표적인 활용 사례입니다.
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
interface RouteDefinition {
path: string;
method: HttpMethod;
}
function defineRoutes<const T extends readonly RouteDefinition[]>(routes: T) {
return {
routes,
getRoute<P extends T[number]["path"]>(path: P) {
return routes.find((r) => r.path === path) as Extract<T[number], { path: P }>;
},
};
}
const router = defineRoutes([
{ path: "/api/users", method: "GET" },
{ path: "/api/users", method: "POST" },
{ path: "/api/posts/:id", method: "GET" },
]);
// path 자동완성: "/api/users" | "/api/posts/:id"
const route = router.getRoute("/api/users");
// route.method: "GET" | "POST"function createEventMap<
const T extends Record<string, (...args: any[]) => void>
>(handlers: T) {
return {
emit<K extends keyof T>(event: K, ...args: Parameters<T[K]>) {
handlers[event](...args);
},
};
}
const events = createEventMap({
click: (x: number, y: number) => console.log(`Click at ${x}, ${y}`),
keydown: (key: string) => console.log(`Key: ${key}`),
resize: (width: number, height: number) => console.log(`${width}x${height}`),
});
events.emit("click", 100, 200); // OK
events.emit("keydown", "Enter"); // OK
events.emit("click", "wrong"); // Error: string은 number에 할당 불가const 타입 파라미터는 객체/배열의 즉각적인 리터럴에만 영향을 줍니다. 이미 변수에 할당된 값은 해당 변수의 타입을 따릅니다.
const data = { port: 3000 }; // data: { port: number }
createConfig(data); // T: { port: number } — const가 적용되지 않음TypeScript에서 변수에 타입을 부여하는 방법은 크게 두 가지입니다.
// 방법 1: 타입 주석 — 타입 검증 O, 리터럴 정보 X
type Colors = Record<string, [number, number, number]>;
const colors: Colors = {
red: [255, 0, 0],
green: [0, 255, 0],
blue: [0, 0, 255],
};
colors.red; // [number, number, number]
colors.purple; // 에러 없음 — Record<string, ...>이므로
// 방법 2: 타입 추론 — 리터럴 정보 O, 타입 검증 X
const colors2 = {
red: [255, 0, 0],
green: [0, 255, 0],
blue: [0, 0, 2555], // 오타! 하지만 에러 없음
};
colors2.red; // number[] — 튜플이 아닌 배열
colors2.purple; // Error — 존재하지 않는 키타입 주석을 쓰면 키 이름과 값의 구체적 정보를 잃고, 타입 추론에만 의존하면 값의 형태를 검증할 수 없습니다.
satisfies 연산자(TypeScript 4.9 도입)는 이 딜레마를 해결합니다. 타입 검증은 수행하되, 추론된 타입을 그대로 유지합니다.
type Colors = Record<string, [number, number, number]>;
const colors = {
red: [255, 0, 0],
green: [0, 255, 0],
blue: [0, 0, 255],
} satisfies Colors;
colors.red; // [number, number, number] — 검증됨
colors.purple; // Error — "purple"은 존재하지 않는 키
colors.blue[0]; // number — 안전하게 인덱싱 가능satisfies는 오른쪽의 타입을 만족하는지 검사만 하고, 변수의 실제 타입은 추론된 그대로 유지합니다.
satisfies는 다음 두 가지를 동시에 수행합니다.
이는 as(타입 단언)와 근본적으로 다릅니다. as는 타입 검사를 우회하지만, satisfies는 검사를 강화합니다.
type Config = { port: number; host: string };
// as: 타입 검사 우회 — 위험
const config1 = { port: "3000" } as Config;
// 에러 없음! port가 string인데도 통과
// satisfies: 타입 검사 수행 — 안전
const config2 = { port: "3000" } satisfies Config;
// Error: string은 number에 할당할 수 없음interface DatabaseConfig {
host: string;
port: number;
ssl: boolean;
poolSize?: number;
}
const dbConfig = {
host: "db.example.com",
port: 5432,
ssl: true,
poolSize: 10,
} satisfies DatabaseConfig;
// dbConfig.host: string (검증됨)
// dbConfig.poolSize: number (undefined가 아닌 number — 값이 있으므로)satisfies가 특히 빛나는 순간은 유니온 타입을 포함한 설정 객체입니다. 타입 주석으로는 각 프로퍼티가 유니온의 어떤 멤버인지 알 수 없지만, satisfies는 실제 값에 기반한 좁은 타입을 유지합니다.
type State = "idle" | "loading" | "success" | "error";
type Transition = {
target: State;
guard?: (context: any) => boolean;
};
type StateMachine = Record<State, Record<string, Transition>>;
const machine = {
idle: {
FETCH: { target: "loading" },
},
loading: {
RESOLVE: { target: "success" },
REJECT: { target: "error" },
},
success: {
RESET: { target: "idle" },
},
error: {
RETRY: { target: "loading" },
RESET: { target: "idle" },
},
} satisfies StateMachine;
// machine.loading.RESOLVE.target: "success" (리터럴 타입 보존)
// machine.idle.NONEXISTENT // Error — 존재하지 않는 이벤트두 기능을 결합하면 더욱 강력한 타입 안전성을 얻을 수 있습니다.
interface MenuItem {
label: string;
href: string;
icon?: string;
}
function createMenu<const T extends readonly MenuItem[]>(items: T) {
return items;
}
const menu = createMenu([
{ label: "Home", href: "/" },
{ label: "About", href: "/about" },
{ label: "Blog", href: "/blog", icon: "pencil" },
] satisfies readonly MenuItem[]);
// 각 항목의 label, href가 리터럴 타입으로 보존되면서도
// MenuItem 인터페이스를 만족하는지 검증됨
type MenuLabels = (typeof menu)[number]["label"];
// "Home" | "About" | "Blog"interface TranslationSchema {
[key: string]: string | TranslationSchema;
}
function defineTranslations<const T extends TranslationSchema>(translations: T) {
return translations;
}
const ko = defineTranslations({
common: {
save: "저장",
cancel: "취소",
delete: "삭제",
},
auth: {
login: "로그인",
logout: "로그아웃",
signup: "회원가입",
},
} satisfies TranslationSchema);
// ko.common.save: "저장" (리터럴 타입)
// ko.auth.login: "로그인"
type TranslationKeys = keyof typeof ko; // "common" | "auth"as const를 요구하고 싶지 않을 때Record 타입으로 검증하되 구체적 키 이름을 보존하고 싶을 때// 불필요한 satisfies — 이미 타입이 명확한 경우
const port = 3000 satisfies number; // 의미 없음
// 불필요한 const — 이미 리터럴 타입인 경우
function identity<const T extends string>(value: T): T {
return value;
}
// string 리터럴은 기본적으로 좁게 추론되므로 const 불필요const 타입 파라미터와 satisfies는 TypeScript의 타입 추론을 정밀하게 제어하는 상호 보완적인 도구입니다. const는 제네릭 함수에서 리터럴 타입 소실을 방지하고, satisfies는 타입 검증과 타입 추론을 동시에 달성합니다. 두 기능을 결합하면 라이브러리 설계에서 사용자에게 최소한의 타입 작성으로 최대한의 타입 안전성을 제공할 수 있습니다.
다음 장에서는 TypeScript 5.0의 또 다른 핵심 기능인 TC39 표준 데코레이터를 살펴봅니다. 기존 실험적 데코레이터와의 차이점, 새로운 데코레이터 API, 그리고 NestJS와 같은 프레임워크에서의 마이그레이션 전략을 다룹니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
TypeScript 5.0에서 도입된 TC39 Stage 3 데코레이터의 원리, API 구조, 실전 패턴을 다루고, 기존 실험적 데코레이터와의 차이를 분석합니다.
TypeScript 5.x 시리즈의 주요 변경사항을 버전별로 정리하고, 타입 시스템의 진화 방향과 개발자 경험 개선을 조망합니다.
TypeScript 5.2에서 도입된 using 선언과 Symbol.dispose를 활용한 명시적 리소스 관리 패턴을 실전 예제와 함께 심층 분석합니다.