7.3 satisfies 연산자
TypeScript 코드를 작성할 때 타입을 명시하는 방법은 여러 가지가 있습니다. 타입 어노테이션(: Type), 타입 단언(as Type), 그리고 TypeScript 4.9에서 추가된 satisfies 연산자입니다. 세 방법은 비슷해 보이지만 동작 방식이 근본적으로 다릅니다. satisfies는 "이 값이 이 타입을 만족하는지 검사하되, 추론된 리터럴 타입은 그대로 유지해"라는 의미입니다.
satisfies란 무엇인가
satisfies 연산자는 두 가지 일을 동시에 합니다.
- 타입 검사: 값이 지정한 타입을 만족하는지 컴파일 타임에 확인합니다.
- 리터럴 타입 보존: 값의 구체적인(좁은) 타입 추론을 잃지 않습니다.
type Color = "red" | "green" | "blue";
interface Palette {
primary: Color | [number, number, number];
secondary: Color | [number, number, number];
background: Color | [number, number, number];
}
// 타입 어노테이션: 타입 검사 O, 리터럴 타입 X (넓어짐)
const palette1: Palette = {
primary: "red",
secondary: [255, 128, 0],
background: "blue",
};
// palette1.primary의 타입: Color | [number, number, number]
// "red"라는 사실을 잃어버림
// satisfies: 타입 검사 O, 리터럴 타입 O (보존됨)
const palette2 = {
primary: "red",
secondary: [255, 128, 0],
background: "blue",
} satisfies Palette;
// palette2.primary의 타입: "red"
// palette2.secondary의 타입: [number, number, number]
// palette2.background의 타입: "blue"
// 리터럴 타입이 보존되므로 배열 메서드도 안전하게 사용 가능
palette2.secondary.map(v => v * 2); // OK — 배열임을 알고 있음
// palette1.secondary.map(v => v * 2); // Error — Color | [...] 이라 map이 없을 수 있음
as와의 차이
as(타입 단언)는 TypeScript에게 "내가 이 타입이라고 단언할게, 믿어줘"라고 강제하는 것입니다. 타입 검사를 우회합니다.
type Status = "active" | "inactive" | "pending";
// as 단언: 타입 검사 X, 잘못된 값도 통과시킴
const status1 = "actve" as Status; // 오타인데 에러 없음!
// satisfies: 타입 검사 O, 잘못된 값은 에러
// const status2 = "actve" satisfies Status; // Error: "actve"는 Status에 없음
const status2 = "active" satisfies Status; // OK
// as를 사용한 위험한 패턴
interface User {
id: number;
name: string;
email: string;
}
// as는 완전히 다른 타입으로도 단언 가능 (이중 단언)
const fakeUser = {} as User; // 컴파일 통과, 런타임 에러 위험
// satisfies는 실제 값이 타입을 만족해야 함
// const fakeUser2 = {} satisfies User; // Error: id, name, email 모두 없음
// as의 유일한 합법적 사용 사례: 타입 좁히기가 불가능한 경우
function processValue(value: unknown): string {
if (typeof value === "string") return value;
return (value as string); // 이미 런타임 검사를 했을 때
}
satisfies vs as 비교 실전
// 설정 객체 예시
type AppConfig = {
port: number;
host: string;
debug: boolean;
features: string[];
};
// as 방식: 타입 안전하지 않음
const config1 = {
port: 3000,
host: "localhost",
debug: true,
features: ["auth", "logging"],
unknownProp: "oops", // 에러 없음! as가 막지 않음
} as AppConfig;
// satisfies 방식: 초과 프로퍼티 검사 포함
// const config2 = {
// port: 3000,
// host: "localhost",
// debug: true,
// features: ["auth", "logging"],
// unknownProp: "oops", // Error: AppConfig에 unknownProp 없음
// } satisfies AppConfig;
// 올바른 사용
const config2 = {
port: 3000,
host: "localhost",
debug: true,
features: ["auth", "logging"],
} satisfies AppConfig;
// 리터럴 타입 보존: 3000이지 number 전체가 아님
type ConfigPort = typeof config2.port; // 3000 (리터럴)
type ConfigHost = typeof config2.host; // "localhost" (리터럴)
명시적 타입 어노테이션과의 차이
타입 어노테이션(: Type)은 변수의 타입을 선언된 타입으로 넓힙니다. 추론된 리터럴 타입이 사라집니다.
type Direction = "north" | "south" | "east" | "west";
// 어노테이션: 타입이 Direction으로 넓어짐
const dir1: Direction = "north";
type Dir1Type = typeof dir1; // Direction (리터럴 아님)
// satisfies: Direction 조건 검사 + 리터럴 보존
const dir2 = "north" satisfies Direction;
type Dir2Type = typeof dir2; // "north" (리터럴)
// 객체에서의 차이
interface RouteConfig {
path: string;
method: "GET" | "POST" | "PUT" | "DELETE";
handler: string;
}
// 어노테이션: 모든 프로퍼티가 RouteConfig 기준으로 넓어짐
const route1: RouteConfig = {
path: "/users",
method: "GET",
handler: "getUsers",
};
type Route1Method = typeof route1.method; // "GET" | "POST" | "PUT" | "DELETE"
// satisfies: 개별 값의 리터럴 타입 보존
const route2 = {
path: "/users",
method: "GET",
handler: "getUsers",
} satisfies RouteConfig;
type Route2Method = typeof route2.method; // "GET" (리터럴)
type Route2Path = typeof route2.path; // string (리터럴 문자열은 string으로 추론)
// 활용: 라우트 방법을 스위치문에서 exhaustive 체크
function handleRoute(method: typeof route2.method): void {
// method는 "GET"이므로 다른 case는 도달 불가
if (method === "GET") {
console.log("handling GET");
}
}
satisfies + as const 조합 패턴
as const와 satisfies를 조합하면 최대한 좁은 타입을 유지하면서 타입 제약도 걸 수 있습니다.
type ThemeColor = string;
interface Theme {
colors: Record<string, ThemeColor>;
spacing: Record<string, number | string>;
breakpoints: Record<string, number>;
}
// as const만 사용: 타입 제약 없음
const themeConst = {
colors: { primary: "#007bff", secondary: "#6c757d" },
spacing: { sm: 8, md: 16, lg: 24 },
breakpoints: { mobile: 768, tablet: 1024 },
} as const;
// satisfies만 사용: 타입 제약 있음, 리터럴 보존 일부
const themeSatisfies = {
colors: { primary: "#007bff", secondary: "#6c757d" },
spacing: { sm: 8, md: 16, lg: 24 },
breakpoints: { mobile: 768, tablet: 1024 },
} satisfies Theme;
// as const + satisfies 조합: 최대 좁은 타입 + 타입 제약
const theme = {
colors: { primary: "#007bff", secondary: "#6c757d" },
spacing: { sm: 8, md: 16, lg: 24 },
breakpoints: { mobile: 768, tablet: 1024 },
} as const satisfies Theme;
// 결과
type PrimaryColor = typeof theme.colors.primary; // "#007bff" (리터럴)
type MobileBreakpoint = typeof theme.breakpoints.mobile; // 768 (리터럴)
// 오류도 잡아줌
// const badTheme = {
// colors: { primary: 12345 }, // Error: number는 ThemeColor(string)가 아님
// } as const satisfies Theme;
// 실용 예: 아이콘 맵
type IconName = "home" | "settings" | "user" | "bell";
const ICONS = {
home: "/icons/home.svg",
settings: "/icons/settings.svg",
user: "/icons/user.svg",
bell: "/icons/bell.svg",
} as const satisfies Record<IconName, string>;
// IconName 외 키는 에러
// const BAD_ICONS = {
// invalid: "/icons/invalid.svg", // Error
// } satisfies Record<IconName, string>;
type IconUrl = typeof ICONS.home; // "/icons/home.svg" (리터럴)
실전 예제 1: 설정 객체 타입 안전성
// 환경별 설정 타입 안전하게 정의
type Environment = "development" | "staging" | "production";
interface DatabaseConfig {
host: string;
port: number;
name: string;
ssl: boolean;
poolSize: number;
}
interface ServerConfig {
host: string;
port: number;
cors: {
origins: string[];
methods: Array<"GET" | "POST" | "PUT" | "DELETE" | "PATCH">;
};
}
interface AppConfig {
env: Environment;
server: ServerConfig;
database: DatabaseConfig;
features: {
auth: boolean;
logging: boolean;
rateLimiting: boolean;
};
}
const devConfig = {
env: "development",
server: {
host: "localhost",
port: 3000,
cors: {
origins: ["http://localhost:3000", "http://localhost:5173"],
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
},
},
database: {
host: "localhost",
port: 5432,
name: "myapp_dev",
ssl: false,
poolSize: 5,
},
features: {
auth: true,
logging: true,
rateLimiting: false,
},
} satisfies AppConfig;
// 리터럴 타입 보존
type DevEnv = typeof devConfig.env; // "development"
type DevPort = typeof devConfig.server.port; // 3000
type DevDbSsl = typeof devConfig.database.ssl; // false
// 설정 함수: 반환 타입이 정확하게 추론됨
function getConfig(env: Environment) {
const configs = {
development: devConfig,
staging: { ...devConfig, env: "staging" as const },
production: {
...devConfig,
env: "production" as const,
database: { ...devConfig.database, ssl: true, poolSize: 20 },
},
} satisfies Record<Environment, AppConfig>;
return configs[env];
}
const prodConfig = getConfig("production");
type ProdSsl = typeof prodConfig.database.ssl; // boolean (satisfies로 넓어짐, 단 타입 안전)
실전 예제 2: 라우트 맵 정의
// 타입 안전한 라우트 레지스트리
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
interface RouteDefinition {
method: HttpMethod;
path: string;
description: string;
requiresAuth: boolean;
}
type RouteRegistry = Record<string, RouteDefinition>;
const API_ROUTES = {
listUsers: {
method: "GET",
path: "/api/users",
description: "Get all users",
requiresAuth: true,
},
createUser: {
method: "POST",
path: "/api/users",
description: "Create a new user",
requiresAuth: true,
},
getUser: {
method: "GET",
path: "/api/users/:id",
description: "Get user by ID",
requiresAuth: true,
},
updateUser: {
method: "PUT",
path: "/api/users/:id",
description: "Update user",
requiresAuth: true,
},
deleteUser: {
method: "DELETE",
path: "/api/users/:id",
description: "Delete user",
requiresAuth: true,
},
health: {
method: "GET",
path: "/health",
description: "Health check",
requiresAuth: false,
},
} satisfies RouteRegistry;
// 각 라우트의 method가 리터럴 타입으로 보존됨
type ListUsersMethod = typeof API_ROUTES.listUsers.method; // "GET"
type CreateUserMethod = typeof API_ROUTES.createUser.method; // "POST"
// 잘못된 method는 컴파일 에러
// const BAD_ROUTES = {
// badRoute: { method: "INVALID", path: "/", ... } // Error
// } satisfies RouteRegistry;
// 라우트 이름을 키로 활용
type RouteName = keyof typeof API_ROUTES;
// "listUsers" | "createUser" | "getUser" | "updateUser" | "deleteUser" | "health"
// 인증이 필요한 라우트만 필터링 (satisfies로 정확한 타입 추론)
type AuthenticatedRoutes = {
[K in RouteName]: typeof API_ROUTES[K]["requiresAuth"] extends true ? K : never;
}[RouteName];
// "listUsers" | "createUser" | "getUser" | "updateUser" | "deleteUser"
실전 예제 3: 팔레트 색상 타입
// 디자인 토큰 타입 안전하게 정의
type HexColor = `#${string}`;
type RgbColor = `rgb(${number}, ${number}, ${number})`;
type HslColor = `hsl(${number}, ${number}%, ${number}%)`;
type CssColor = HexColor | RgbColor | HslColor;
interface ColorShades {
50: CssColor;
100: CssColor;
200: CssColor;
300: CssColor;
400: CssColor;
500: CssColor;
600: CssColor;
700: CssColor;
800: CssColor;
900: CssColor;
}
interface DesignSystem {
colors: {
primary: ColorShades;
neutral: ColorShades;
error: Partial<ColorShades>;
success: Partial<ColorShades>;
};
}
const designTokens = {
colors: {
primary: {
50: "#eff6ff",
100: "#dbeafe",
200: "#bfdbfe",
300: "#93c5fd",
400: "#60a5fa",
500: "#3b82f6",
600: "#2563eb",
700: "#1d4ed8",
800: "#1e40af",
900: "#1e3a8a",
},
neutral: {
50: "#f9fafb",
100: "#f3f4f6",
200: "#e5e7eb",
300: "#d1d5db",
400: "#9ca3af",
500: "#6b7280",
600: "#4b5563",
700: "#374151",
800: "#1f2937",
900: "#111827",
},
error: {
500: "#ef4444",
700: "#b91c1c",
},
success: {
500: "#22c55e",
700: "#15803d",
},
},
} satisfies DesignSystem;
// 리터럴 타입으로 색상 값에 접근
type Primary500 = typeof designTokens.colors.primary["500"]; // "#3b82f6"
// 색상 유틸리티 함수 (리터럴 타입 덕분에 정확한 타입 추론)
function getColor<
Category extends keyof typeof designTokens.colors,
Shade extends keyof typeof designTokens.colors[Category]
>(
category: Category,
shade: Shade
): typeof designTokens.colors[Category][Shade] {
return designTokens.colors[category][shade] as any;
}
const primaryBlue = getColor("primary", "500");
// 타입: "#3b82f6"
고수 팁
satisfies의 한계: 추론 depth
satisfies는 타입 추론을 넓히지 않지만, 깊은 구조에서는 예상과 다르게 동작할 수 있습니다.
// 배열 내부는 리터럴 타입으로 보존되지 않을 수 있음
interface Config {
tags: string[];
}
const config = {
tags: ["typescript", "react", "node"],
} satisfies Config;
type Tags = typeof config.tags; // string[] (리터럴 배열이 아님)
// as const + satisfies로 해결
const configConst = {
tags: ["typescript", "react", "node"],
} as const satisfies Config;
type TagsConst = typeof configConst.tags;
// readonly ["typescript", "react", "node"] (튜플 리터럴)
// 함수 타입 추론의 한계
interface Actions {
onClick: (event: MouseEvent) => void;
}
const actions = {
onClick: (e) => console.log(e.clientX), // e는 MouseEvent로 추론됨
} satisfies Actions;
// satisfies 안에서 파라미터 타입은 맥락적으로 추론됨 — 이 경우는 잘 동작
언제 어노테이션 vs satisfies 선택
// 어노테이션을 선택해야 하는 경우:
// 1. 변수 타입을 의도적으로 넓혀야 할 때
let message: string = "hello"; // 나중에 다른 string을 재할당할 것이므로
// message = "world"; // OK
// 2. 추론된 타입이 너무 좁아서 오히려 문제가 될 때
function processStatus(status: string): void {
// 내부에서 다양한 처리
}
const STATUS: string = "active"; // processStatus에 넘길 것이므로 string이 필요
processStatus(STATUS); // OK
// satisfies를 선택해야 하는 경우:
// 1. 타입 안전성 검사 + 정확한 타입 추론이 모두 필요할 때
const httpMethods = ["GET", "POST", "PUT"] satisfies Array<HttpMethod>;
// httpMethods[0]은 "GET" (리터럴) | "POST" | "PUT" — 배열이지만 내용 타입 보존
// 2. 설정 객체에서 키를 타입으로 활용할 때
const featureFlags = {
darkMode: false,
notifications: true,
betaFeatures: false,
} satisfies Record<string, boolean>;
type FeatureFlag = keyof typeof featureFlags;
// "darkMode" | "notifications" | "betaFeatures" (satisfies 덕분에 키가 보존됨)
// 어노테이션을 사용했다면: string (키가 사라짐)
// 3. 컴파일 타임 상수 정의 시
const MAX_RETRY = 3 satisfies number;
// typeof MAX_RETRY는 3 (리터럴), 하지만 number 타입임을 검증
정리
| 구문 | 타입 검사 | 리터럴 보존 | 잘못된 값 | 주요 사용 |
|---|---|---|---|---|
: Type (어노테이션) | O | X (넓어짐) | 에러 | 변수 타입 선언 |
as Type (단언) | X (강제) | X | 에러 없음 | 타입 강제 변환 |
satisfies Type | O | O | 에러 | 설정 객체, 상수 |
as const satisfies Type | O | O (최대) | 에러 | 불변 상수 |
다음 장에서는 const 타입 파라미터, NoInfer<T>, using 키워드 등 TypeScript 5.x의 신기능들을 살펴보고, 최신 TypeScript가 제공하는 강력한 타입 추론과 리소스 관리 기능을 익힙니다.