- Published on
TS 5.x 초고급 타입 추론 - satisfies·infer·const
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/프론트가 커질수록 타입은 단순히 “에러를 막는 도구”가 아니라, 리팩터링 속도와 API 설계 품질을 좌우하는 핵심 자산이 됩니다. TypeScript 5.x는 타입 시스템을 더 “표현력 있게” 만들면서도, 런타임 코드를 늘리지 않는 방향으로 진화했고 그 중심에 satisfies, infer, 그리고 다양한 의미의 const가 있습니다.
이 글에서는 세 기능을 각자 따로가 아니라, 실제 코드에서 서로 조합해 추론을 극대화하는 방식으로 정리합니다.
참고: Next.js 앱에서 타입 설계를 다듬는 맥락이 궁금하다면 Next.js 14 서버컴포넌트와 클라이언트 상태 동기화도 함께 보면 좋습니다.
1) satisfies: “검증”과 “추론 유지”를 동시에
1-1. as 캐스팅의 문제: 추론을 죽이거나, 타입 안전을 죽이거나
많은 코드에서 설정 객체나 라우트 테이블을 만들 때 아래처럼 작성합니다.
type Route = {
path: string;
method: "GET" | "POST";
};
const routes: Record<string, Route> = {
health: { path: "/health", method: "GET" },
createUser: { path: "/users", method: "POST" },
};
이 방식은 “검증”은 되지만, routes.health.method 같은 값의 리터럴 정보가 잘 보존되지 않는 경우가 많습니다. Record<string, Route>로 한 번 감싸는 순간, 키도 string으로 넓어지고 내부도 넓어지기 쉽습니다.
반대로 as Route 같은 캐스팅을 남발하면, 실수로 method: "PUT"을 넣어도 컴파일러가 눈감아버릴 수 있습니다.
1-2. satisfies의 핵심: 타입을 “강제”하지 않고 “충족 여부만” 검사
routes의 실제 타입 추론은 유지하면서, 동시에 특정 인터페이스를 만족하는지 검증하고 싶다면 satisfies가 정답입니다.
type Route = {
path: `/${string}`;
method: "GET" | "POST";
};
type RouteTable = Record<string, Route>;
const routes = {
health: { path: "/health", method: "GET" },
createUser: { path: "/users", method: "POST" },
} satisfies RouteTable;
이제
routes.health.method는 가능한 한 리터럴로 유지됩니다.path는 반드시/로 시작해야 합니다(템플릿 리터럴 타입 검증).routes의 키는 여전히"health" | "createUser"같은 구체적 유니온으로 남습니다.
1-3. satisfies + as const: “값을 고정”하고 “구조를 검증”
as const는 값을 최대한 리터럴로 고정합니다. 다만 as const만 쓰면 구조 검증이 약해질 수 있으니 satisfies와 함께 쓰면 좋습니다.
type FeatureFlag = {
owner: string;
rollout: 0 | 10 | 50 | 100;
};
type FeatureFlags = Record<string, FeatureFlag>;
const flags = {
newCheckout: { owner: "pay-team", rollout: 10 },
fastSearch: { owner: "search-team", rollout: 50 },
} as const satisfies FeatureFlags;
여기서 얻는 이점:
flags.newCheckout.rollout는10으로 고정rollout에30을 넣으면 즉시 타입 에러- 키는
"newCheckout" | "fastSearch"로 유지
이 패턴은 설정/메타데이터 테이블, 권한 매트릭스, 이벤트 스키마 목록에서 특히 강력합니다.
2) infer: 타입에서 “부분을 뽑아내는” 고급 추론 도구
infer는 조건부 타입(T extends ... ? ... : ...) 안에서만 사용할 수 있는 키워드로, “어떤 타입의 일부를 변수처럼 바인딩”해 재사용할 수 있게 해줍니다.
2-1. 함수 반환 타입/인자 타입 뽑기 (직접 구현해보기)
표준 라이브러리에 ReturnType, Parameters가 있지만, 직접 만들어 보면 infer의 감각이 잡힙니다.
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type MyParameters<T> = T extends (...args: infer P) => any ? P : never;
function fetchUser(id: string) {
return { id, name: "kim" as const };
}
type R1 = MyReturnType<typeof fetchUser>; // { id: string; name: "kim" }
type P1 = MyParameters<typeof fetchUser>; // [id: string]
2-2. Promise 언랩: Awaited를 이해하는 방식
비동기 코드에서 자주 필요한 “Promise 내부 타입” 추출도 infer로 가능합니다.
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type A = UnwrapPromise<Promise<number>>; // number
type B = UnwrapPromise<string>; // string
실제로는 중첩 Promise, thenable 등을 고려해야 해서 TS 내장 Awaited가 더 안전하지만, 기본 원리는 동일합니다.
2-3. 템플릿 리터럴 타입 + infer: 문자열 스키마 파싱
TS 5.x에서 문자열 기반 규칙을 타입으로 강제하는 패턴이 많이 쓰입니다. 예를 들어 "user:create" 같은 이벤트 이름을 "domain:action"으로 강제하고 싶다고 합시다.
type ParseEvent<T extends string> =
T extends `${infer Domain}:${infer Action}`
? { domain: Domain; action: Action }
: never;
type E1 = ParseEvent<"user:create">; // { domain: "user"; action: "create" }
type E2 = ParseEvent<"invalid">; // never
이걸 기반으로 이벤트 핸들러 맵을 더 안전하게 만들 수 있습니다.
type EventName = "user:create" | "user:delete" | "order:paid";
type HandlerMap = {
[K in EventName]: (payload: ParseEvent<K>) => void;
};
const handlers: HandlerMap = {
"user:create": (p) => {
// p.domain === "user", p.action === "create"
},
"user:delete": (p) => {},
"order:paid": (p) => {},
};
3) TS 5.x의 const: as const만 있는 게 아니다
TypeScript에서 const는 문맥에 따라 의미가 다릅니다. TS 5.x를 “완전 정복”하려면 아래 3가지를 구분해야 합니다.
- 값 레벨의
const선언 - 타입 레벨의
as const(const assertion) - 제네릭/타입 추론을 강화하는
const관련 기능(예: const type parameter)
3-1. as const: 리터럴을 최대한 좁히는 고전적 핵심
const roles = ["admin", "member", "guest"] as const;
// readonly ["admin", "member", "guest"]
type Role = (typeof roles)[number];
// "admin" | "member" | "guest"
as const는 “배열을 튜플로”, “프로퍼티를 readonly로”, “값을 리터럴로” 좁히는 역할을 합니다.
3-2. const type parameter: 인자로 들어온 리터럴을 더 잘 보존
일반적으로 제네릭 함수는 인자를 받으면 타입이 넓어지기 쉽습니다. TS 5.x에서는 const type parameter를 통해 “제네릭 추론에서 리터럴을 더 잘 유지”할 수 있습니다.
// TS 5.x
function pick<const T extends object, const K extends readonly (keyof T)[]>(
obj: T,
keys: K
) {
const out = {} as Pick<T, K[number]>;
for (const k of keys) out[k] = obj[k];
return out;
}
const user = { id: "u1", name: "kim", age: 20 };
const r = pick(user, ["id", "name"]);
// r: { id: string; name: string }
여기서 keys가 string[]으로 쉽게 넓어지지 않고 readonly ["id", "name"]에 가깝게 유지되어 Pick이 정확해집니다.
주의: 프로젝트의 TS 버전/설정에 따라 체감이 다를 수 있습니다. 하지만 대규모 코드베이스에서 “유틸 함수 하나가 추론을 얼마나 잘 유지하느냐”는 생산성에 직결됩니다.
3-3. const + satisfies 조합으로 “키 유니온”을 잃지 않는 레지스트리 만들기
플러그인/커맨드 레지스트리를 만든다고 해봅시다. 목표는 다음입니다.
- 등록 객체의 키를 유니온으로 얻고 싶다
- 각 엔트리는 특정 스키마를 만족해야 한다
- 각 엔트리의 세부 값은 리터럴로 유지되면 좋다
type CommandSpec = {
description: string;
args: readonly string[];
};
type CommandRegistry = Record<string, CommandSpec>;
const commands = {
build: { description: "Build project", args: ["--prod"] },
dev: { description: "Start dev server", args: ["--port"] },
} as const satisfies CommandRegistry;
type CommandName = keyof typeof commands;
// "build" | "dev"
type BuildArgs = (typeof commands)["build"]["args"][number];
// "--prod"
이 패턴은 CLI, 작업 큐, 이벤트 라우터, API 엔드포인트 메타데이터에 그대로 적용할 수 있습니다.
4) satisfies vs as const vs 타입 주석: 언제 무엇을 쓰나
4-1. 선택 가이드
- 값을 리터럴로 고정하고 싶다:
as const - 특정 타입을 만족하는지 검사하면서도 추론은 유지하고 싶다:
satisfies - **그 변수/상수의 타입을 특정 타입으로 “고정”**하고 싶다: 타입 주석(
: Type) - 검증 없이 통과시키고 싶다(대부분 나쁜 선택):
as Type
4-2. 실전 체크리스트
- 설정 객체/테이블:
as const satisfies SomeSchema - 외부 입력(런타임 검증 후): 검증 결과 타입을 좁히고, 그 다음엔
satisfies로 구조 유지 - 유틸 함수: 가능하면
consttype parameter로 리터럴 보존
5) 고급 예제: 타입 안전한 “API 스펙”에서 클라이언트 타입 자동 추론
프론트에서 API 호출을 만들 때, 아래 요구가 자주 등장합니다.
- 엔드포인트 목록을 한 곳에서 관리
- 메서드/경로/요청/응답 타입을 강제
- 호출 함수에서
path를 선택하면 요청/응답 타입이 자동으로 따라오게
5-1. 스펙 정의: satisfies로 스키마 검증
type ApiSpec = {
method: "GET" | "POST";
path: `/${string}`;
// 단순화를 위해 request/response를 unknown으로 두고, 실제로는 zod 등과 결합 가능
request: unknown;
response: unknown;
};
type ApiTable = Record<string, ApiSpec>;
const api = {
getUser: {
method: "GET",
path: "/users",
request: { id: "" as string },
response: { id: "" as string, name: "" as string },
},
createUser: {
method: "POST",
path: "/users",
request: { name: "" as string },
response: { id: "" as string },
},
} satisfies ApiTable;
여기서 api는 스키마를 만족해야 하지만, 키 유니온("getUser" | "createUser")은 유지됩니다.
5-2. infer로 요청/응답을 추출하는 클라이언트
type SpecOf<K extends keyof typeof api> = (typeof api)[K];
type RequestOf<K extends keyof typeof api> = SpecOf<K> extends { request: infer R }
? R
: never;
type ResponseOf<K extends keyof typeof api> = SpecOf<K> extends { response: infer R }
? R
: never;
async function callApi<K extends keyof typeof api>(
key: K,
req: RequestOf<K>
): Promise<ResponseOf<K>> {
// 실제 구현에서는 fetch/axios 등을 사용
// 여기서는 타입 데모용
return api[key].response as ResponseOf<K>;
}
// 사용 예
const u = await callApi("getUser", { id: "u1" });
// u: { id: string; name: string }
const created = await callApi("createUser", { name: "kim" });
// created: { id: string }
이 구조의 장점은 “스펙이 단일 진실 공급원(Single Source of Truth)”이 되고, 호출부에서 문자열 키만 선택해도 요청/응답이 자동으로 고정된다는 점입니다.
6) 타입 추론을 망치는 흔한 함정과 해결
6-1. Record<string, ...>를 너무 일찍 씌우기
초기에 const x: Record<string, T> = ... 형태로 고정하면 키가 string으로 넓어져서 이후 keyof typeof x의 가치가 떨어집니다.
해결:
- 먼저 객체 리터럴을 만들고
satisfies로 검증만 걸고- 키 유니온은
keyof typeof로 가져옵니다.
6-2. “타입을 맞추기 위해” as로 눌러버리기
as는 마지막 수단이어야 합니다. 특히 스키마/레지스트리/라우트 테이블에서 as는 장기적으로 타입 부채를 만듭니다.
해결:
satisfies로 바꾸고- 필요한 곳에만
as const - 유틸 함수에는
consttype parameter를 고려
6-3. 문자열 규칙을 런타임에만 의존하기
예: 이벤트 이름이 "domain:action" 규칙이라면, 타입에서 템플릿 리터럴 + infer로 강제해두면 API 사용성이 좋아집니다.
7) 마무리: 세 키워드를 “조합”했을 때 얻는 것
satisfies: 구조 검증을 하면서 추론을 유지infer: 타입에서 필요한 조각을 뽑아 자동 전파const(특히as const, const type parameter): 리터럴/튜플 정보를 유지해 정밀한 추론
이 세 가지를 함께 쓰면, “타입이 귀찮은 장식”이 아니라 도메인 규칙을 컴파일 타임에 고정하는 설계 도구가 됩니다. 특히 레지스트리/스펙/라우팅/이벤트 같은 메타데이터 중심 코드에서 효과가 큽니다.
추가로, 추론/에이전트 설계 관점에서 “정보를 어떻게 구조화해 누출 없이 추론하게 만들까”에 관심이 있다면 CoT 누출 없이 ReAct·스크래치패드로 추론도 같이 읽어보면 사고방식에 도움이 됩니다.