- Published on
TS 5.x satisfies로 타입추론 깨짐 해결하기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 설정, 라우트 테이블, 이벤트 핸들러 맵, 권한 매트릭스 같은 “정적 객체 선언”을 하다 보면 TypeScript 타입추론이 쉽게 깨집니다. 특히 as SomeType 단언을 붙이는 순간 리터럴 타입이 넓어지거나(예: "GET"이 string으로), 키-값 관계가 흐려져서(예: 특정 키에만 존재하는 필드가 사라짐) 이후 코드에서 자동완성/검증이 약해지는 경우가 많습니다.
TS 4.9부터 도입되고 5.x에서 사실상 표준 패턴이 된 satisfies는 이 문제를 해결하는 데 매우 효과적입니다. 핵심은 “타입을 강제로 바꾸지 않고, 그 타입을 만족하는지만 검사” 한다는 점입니다. 즉, 객체의 구체적인 리터럴 타입 추론은 유지하면서도, 원하는 형태(스키마)를 만족하는지 컴파일 타임에 검증할 수 있습니다.
이 글에서는 satisfies가 왜 타입추론 깨짐을 막는지, as/타입 주석과의 차이, 그리고 실전에서 자주 쓰는 패턴(라우트/설정/권한/레지스트리)을 코드로 정리합니다.
참고로 TS 5.x 관련 다른 트러블슈팅은 TS 5.x 데코레이터 적용 시 Unable to resolve signature 해결도 함께 보면 좋습니다.
타입추론이 깨지는 대표 패턴
1) 타입 주석으로 리터럴이 넓어지는 문제
아래처럼 객체에 타입 주석을 달면, 값이 리터럴이어도 타입이 넓어질 수 있습니다.
type Env = {
mode: "dev" | "prod";
region: "ap-northeast-2" | "us-east-1";
};
const env: Env = {
mode: "dev",
region: "ap-northeast-2",
};
// env.mode의 타입은 "dev" | "prod" (넓음)
// 실제 값은 "dev"인데 이후 분기 최적화/좁히기가 덜 강력해질 수 있음
이 자체가 항상 문제는 아니지만, **“선언 시점의 구체적인 값(리터럴)을 최대한 유지하고 싶다”**면 불리합니다.
2) as 단언으로 검증이 사라지는 문제
as는 통과시키고 싶은 욕구를 충족시키지만, 잘못 쓰면 검증이 사라집니다.
type Env = {
mode: "dev" | "prod";
region: "ap-northeast-2" | "us-east-1";
};
const env = {
mode: "deev", // 오타
region: "ap-northeast-2",
} as Env;
// 컴파일 에러가 안 날 수 있음(상황에 따라 경고 없이 통과)
// 런타임에서만 터지는 구성
실무에서 설정 객체나 상수 테이블을 as로 “맞춰버리는” 순간, 타입 시스템의 안전망이 약해집니다.
satisfies가 해결하는 것: 검증과 추론의 분리
value satisfies Type는 다음 두 가지를 동시에 달성합니다.
- Type을 만족하는지 검사한다 (부족한 필드/잘못된 값/여분 필드 등)
- value 자체의 타입은 최대한 구체적으로 추론한다 (리터럴, 키 집합, 구체적인 구조)
예시로 보면 바로 감이 옵니다.
type Env = {
mode: "dev" | "prod";
region: "ap-northeast-2" | "us-east-1";
};
const env = {
mode: "dev",
region: "ap-northeast-2",
} satisfies Env;
// env.mode의 타입은 "dev" (리터럴 유지)
// 하지만 Env 스키마를 만족하는지 컴파일 타임에 검증됨
오타를 넣으면 즉시 에러가 납니다.
const env = {
mode: "deev",
region: "ap-northeast-2",
} satisfies Env;
// ^^^^^ Type '"deev"' is not assignable to type '"dev" | "prod"'
언제 satisfies가 특히 강력한가
1) 라우트/핸들러 맵에서 키-값 관계 보존
라우트 테이블은 대표적으로 “타입추론이 깨지기 쉬운” 구조입니다. 키(경로)와 값(메서드, 권한, 핸들러 시그니처)을 함께 관리하면서, 키 목록은 그대로 유지하고 싶기 때문입니다.
type RouteSpec = {
method: "GET" | "POST";
auth: "public" | "user" | "admin";
};
type RouteTable = Record<string, RouteSpec>;
const routes = {
"/health": { method: "GET", auth: "public" },
"/users": { method: "POST", auth: "admin" },
} satisfies RouteTable;
// routes의 키는 "/health" | "/users" 로 유지됨
// routes["/health"].method는 "GET" 리터럴로 유지됨
이 상태에서 키를 안전하게 뽑아 쓰면, 문자열 오타가 줄어듭니다.
type RouteKey = keyof typeof routes;
function buildUrl(route: RouteKey) {
return `https://api.example.com${route}`;
}
buildUrl("/health");
// buildUrl("/heath"); // 오타는 컴파일 에러
반대로 const routes: RouteTable = { ... }로 선언하면 keyof typeof routes가 string으로 넓어져서 이런 이점이 사라지기 쉽습니다.
2) “여분 필드”를 잡아내는 설정 객체 검증
설정 객체는 종종 “필드 이름이 살짝 틀렸는데도 그냥 지나가는” 문제가 있습니다. satisfies는 객체 리터럴에 대해 초과 속성 검사(excess property check)가 더 잘 걸리도록 도와줍니다.
type AppConfig = {
port: number;
logLevel: "debug" | "info" | "warn" | "error";
};
const config = {
port: 3000,
loglevel: "info", // 오타
} satisfies AppConfig;
// ^^^^^^^ Property 'loglevel' does not exist...
// Property 'logLevel' is missing...
as AppConfig로 덮어버리면 이런 실수를 놓치기 쉽습니다.
3) 권한/정책 매트릭스에서 값의 리터럴 유지
권한 매트릭스는 “값의 리터럴 유지”가 중요합니다. 예를 들어 어떤 액션이 "allow"인지 "deny"인지가 타입으로 남아 있으면, 이후 로직에서 더 강한 좁히기가 됩니다.
type Decision = "allow" | "deny";
type Policy = {
read: Decision;
write: Decision;
};
const policyByRole = {
guest: { read: "allow", write: "deny" },
admin: { read: "allow", write: "allow" },
} satisfies Record<string, Policy>;
// policyByRole.admin.write는 "allow" 리터럴로 유지될 수 있음
4) 플러그인/레지스트리 패턴에서 API 시그니처 검증
플러그인 레지스트리는 “각 엔트리가 특정 시그니처를 만족해야 한다”는 요구가 강합니다.
type Plugin = {
name: string;
setup: (ctx: { env: "dev" | "prod" }) => void;
};
const plugins = [
{
name: "logger",
setup: (ctx: { env: "dev" | "prod" }) => {
if (ctx.env === "dev") console.log("dev");
},
},
{
name: "metrics",
setup: (ctx: { env: "dev" | "prod" }) => {
// ...
},
},
] satisfies Plugin[];
이 패턴의 장점은 배열/객체의 각 원소가 Plugin을 만족하는지 검증하면서도, 개별 원소의 구체적인 값(예: name 리터럴)이 유지될 여지가 있다는 점입니다.
satisfies vs 타입 주석 vs as: 선택 기준
타입 주석 const x: T = ...
- 장점: 선언 지점에서 “x는 T다”가 명확
- 단점: x의 타입이 T로 고정되며 리터럴/구체 정보가 사라질 수 있음
단언 const x = ... as T
- 장점: 빠르게 통과
- 단점: 검증을 약화시키기 쉽고, 잘못된 값도 통과 가능
만족 검사 const x = ... satisfies T
- 장점: 검증은 T 기준으로, 추론은 값 기준으로 가져감
- 단점: x의 타입이 정확히 T가 되지는 않음(필요하면 별도 타입 추출/가공 필요)
실무 기준으로는 다음처럼 많이 정리합니다.
- “값은 구체적으로 유지하고, 스키마만 검사”하고 싶다:
satisfies - “이 변수는 이후 어디서나 T로 취급되어야 한다”: 타입 주석
- “정말로 불가피한 우회(레거시, 외부 라이브러리 결함)”만:
as
실전 패턴: satisfies와 as const의 조합
as const는 객체/배열을 깊게 readonly로 만들고 리터럴을 극대화합니다. 여기에 satisfies를 조합하면 “리터럴 유지 + 스키마 검증”이 함께 됩니다.
type HttpMethod = "GET" | "POST";
type RouteSpec = {
method: HttpMethod;
timeoutMs: number;
};
const routes = {
"/health": { method: "GET", timeoutMs: 200 },
"/users": { method: "POST", timeoutMs: 1500 },
} as const satisfies Record<string, RouteSpec>;
// routes["/users"].timeoutMs는 1500 리터럴
// routes는 readonly 성격을 가져 실수로 수정하는 코드가 줄어듦
주의할 점은, as const만 쓰면 스키마 검증이 약합니다. 반대로 satisfies만 쓰면 readonly까지는 강제되지 않습니다. 둘을 함께 쓰면 선언 안정성이 크게 올라갑니다.
흔한 함정과 해결책
1) satisfies를 썼는데도 타입이 원하는 형태로 안 보일 때
satisfies는 “검증”이지 “변환”이 아닙니다. 그래서 어떤 함수가 T를 요구할 때는 그대로 못 넣는 상황이 생길 수 있습니다.
type Env = { mode: "dev" | "prod" };
const env = { mode: "dev" } satisfies Env;
declare function run(e: Env): void;
run(env); // 보통은 문제 없지만, 복잡한 경우(제네릭/조건부 타입)에서 애매해질 수 있음
이럴 때는 다음 중 하나를 선택합니다.
- 함수 인자를 받는 쪽을 제네릭으로 바꿔 “더 구체적인 타입도 허용”
- 정말 필요하면 별도 변수에 타입 주석으로 고정
const envForRun: Env = env;
이 방식은 “선언부에서는 리터럴 유지 + 검증”을 하고, “경계(boundary)에서는 타입을 고정”하는 접근입니다.
2) Record<string, ...> 때문에 키 추론을 잃는 문제
Record<string, X> 자체는 키를 string으로 넓힙니다. 하지만 satisfies를 쓰면 객체 리터럴의 실제 키 유니온은 유지됩니다.
type Table = Record<string, { enabled: boolean }>;
const features = {
newCheckout: { enabled: true },
betaBanner: { enabled: false },
} satisfies Table;
type FeatureKey = keyof typeof features;
// "newCheckout" | "betaBanner"
키를 좁게 유지하고 싶은 “테이블 선언”에 특히 잘 맞습니다.
3) 값 타입을 너무 빡빡하게 만들어 확장성이 죽는 문제
satisfies는 검증을 강하게 하므로, 스키마가 과도하게 좁으면 확장이 어렵습니다. 예를 들어 timeoutMs를 리터럴 유니온으로 만들어버리면 새 값을 추가할 때 계속 타입을 고쳐야 합니다.
권장 패턴은 다음처럼 “필수 구조만 강제하고, 값 범위는 현실적으로” 두는 것입니다.
type RouteSpec = {
method: "GET" | "POST";
timeoutMs: number; // 리터럴 유니온 대신 number
};
마이그레이션 팁: 기존 as를 satisfies로 바꾸는 순서
- 설정/상수 테이블/라우트 맵처럼 “선언형 객체”부터 시작
- 기존의
as SomeType을 제거하고satisfies SomeType로 교체 - 터지는 타입 에러는 대부분 “오타” 또는 “스키마 불일치”이므로 실제 버그를 조기에 발견하는 효과가 큼
- 경계 지점(함수 인자, 외부로 export되는 타입)에서만 타입 주석으로 고정
CI에서 타입 에러를 빨리 잡는 문화와도 잘 맞습니다. 빌드/테스트 시간을 줄이는 운영 팁은 GitHub Actions 캐시로 Node.js CI 2배 빠르게, 실패 디버깅도 참고할 만합니다.
결론
TS 5.x에서 satisfies는 “타입추론이 깨지는 선언 코드”를 정리하는 데 가장 비용 대비 효과가 큰 도구 중 하나입니다.
as단언으로 검증을 포기하지 말고- 타입 주석으로 리터럴을 넓히기 전에
satisfies로 스키마 검증과 구체 타입 추론을 분리하세요.
라우트/설정/정책/레지스트리 같은 정적 선언부에 적용하면 자동완성과 타입 안정성이 동시에 좋아지고, 런타임에서 터질 문제를 컴파일 타임으로 끌어올릴 수 있습니다.