- Published on
TS 5.x satisfies vs as - 타입오류·추론 차이
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 비슷해 보이는 as와 satisfies는 실제로 목적이 다릅니다. as는 개발자가 타입을 "우겨 넣는"(assert) 수단이고, satisfies는 값이 특정 타입의 요구사항을 "충족하는지"(check) 검증하되 값 자체의 더 구체적인 타입 추론은 유지하는 수단입니다.
TypeScript 5.x에서 satisfies가 널리 쓰이기 시작한 이유는 간단합니다. 객체 리터럴, 설정 객체, 라우팅 테이블, 이벤트 맵 같은 곳에서
- 타입 안정성은 확보하고
- 동시에 자동완성과 리터럴 추론은 최대한 살리고
- 불필요한 타입 단언으로 오류를 숨기지 않기
를 동시에 만족시키기 때문입니다.
아래에서 as와 satisfies가 타입 오류를 내는 지점, 추론 결과, 실무 패턴에서 어떻게 달라지는지 정리합니다.
as는 단언, satisfies는 검증
as: 타입 오류를 "없애는" 방향으로 작동
as SomeType은 컴파일러에게 "이 값은 SomeType이 맞다"고 단언합니다. 따라서 실제 값이 그 타입을 만족하지 않아도, 많은 경우 오류가 사라지거나 다른 곳으로 밀려납니다.
type User = {
id: string;
role: "admin" | "user";
};
// 위험: 실제로는 id가 number인데 단언으로 덮어버림
const u = { id: 123, role: "admin" } as User;
// 이후 코드에서 런타임 버그로 이어질 수 있음
u.id.toUpperCase();
위 코드는 타입체커가 막아야 할 문제를 as가 가려버린 전형적인 사례입니다.
satisfies: 요구사항을 검사하고, 추론은 유지
satisfies SomeType은 "이 값이 SomeType의 조건을 만족하는지" 검사합니다. 중요한 차이는 다음입니다.
- 만족하지 않으면 그 자리에서 타입 오류가 발생
- 만족하더라도 값의 타입이
SomeType으로 "변환"되지 않음 - 즉, 값은 더 구체적인 리터럴 타입을 유지할 수 있음
type User = {
id: string;
role: "admin" | "user";
};
// 즉시 오류: id는 string이어야 함
const u = { id: 123, role: "admin" } satisfies User;
여기서는 오류가 숨지 않고 선언 지점에서 바로 드러납니다.
추론 차이: as는 넓히고, satisfies는 "그대로 둔다"
실무에서 가장 체감되는 차이는 추론 결과입니다. 특히 객체 리터럴에서 as를 쓰면 타입이 넓어지거나(혹은 강제로 바뀌거나) 리터럴 정보가 사라지는 경우가 많습니다.
예시: 리터럴 유지 vs 타입 고정
type RouteConfig = {
method: "GET" | "POST";
path: string;
};
const routesAs = {
listUsers: { method: "GET", path: "/users" },
createUser: { method: "POST", path: "/users" },
} as Record<string, RouteConfig>;
const routesSat = {
listUsers: { method: "GET", path: "/users" },
createUser: { method: "POST", path: "/users" },
} satisfies Record<string, RouteConfig>;
이때 두 변수의 의미는 다릅니다.
routesAs는 "이건Record<string, RouteConfig>다"로 고정됩니다.- 키가 실제로
listUsers와createUser뿐이라는 정보가 약해질 수 있고 - 내부 프로퍼티도
RouteConfig관점으로만 다뤄지기 쉽습니다.
- 키가 실제로
routesSat는 "이 객체는Record<string, RouteConfig>조건을 만족한다"만 보장하고- 객체 자체는 여전히
listUsers,createUser라는 구체 키를 가진 타입으로 남습니다.
- 객체 자체는 여전히
즉, satisfies는 "검증용"이고, as는 "형변환(단언)"에 가깝습니다.
자동완성/인덱싱에서 차이가 크게 난다
아래처럼 키를 뽑아 쓰는 코드에서 차이가 잘 드러납니다.
const routeKeys1 = Object.keys(routesAs);
const routeKeys2 = Object.keys(routesSat);
// routeKeys1, routeKeys2는 둘 다 string[]로 나올 수 있지만,
// 타입 레벨에서 routesSat은 구체 키를 유지하므로
// keyof typeof routesSat 같은 패턴에서 더 강력해짐
type RouteNameAs = keyof typeof routesAs; // string (혹은 매우 넓음)
type RouteNameSat = keyof typeof routesSat; // "listUsers" | "createUser"
keyof 기반으로 라우팅, 이벤트, 커맨드 디스패치 테이블을 만드는 경우 satisfies의 가치가 큽니다.
타입 오류 관점: as는 늦게 터지고, satisfies는 즉시 터진다
오타/누락 프로퍼티 잡기
설정 객체에서 가장 흔한 버그는 오타와 누락입니다.
type JwtConfig = {
issuer: string;
audience: string;
jwksUrl: string;
};
// 1) as: 오타가 있어도 단언으로 통과할 가능성이 큼
const cfgAs = {
issuer: "https://idp.example.com",
audience: "my-api",
jwksURL: "https://idp.example.com/.well-known/jwks.json", // 오타
} as JwtConfig;
// 2) satisfies: 즉시 오류로 잡힘
const cfgSat = {
issuer: "https://idp.example.com",
audience: "my-api",
jwksURL: "https://idp.example.com/.well-known/jwks.json", // 오류: jwksUrl이 아님
} satisfies JwtConfig;
as는 이런 류의 실수를 "타입은 맞다"고 주장하며 지나가게 만들 수 있습니다. 반면 satisfies는 선언 지점에서 바로 막습니다.
JWT 설정처럼 한 번 잘못 배포되면 401이 연쇄적으로 터지는 영역에서는, 이런 검증이 특히 중요합니다. 관련해서는 Node.js JWT 검증 실패 - kid·JWKS 캐시로 401 잡기 같은 글의 맥락과도 연결됩니다.
실무 패턴 1: "맵" 정의는 satisfies가 정답에 가깝다
이벤트 핸들러 맵, 에러 코드 맵, 권한 정책 맵처럼 "키-값" 테이블은 다음 요구가 동시에 존재합니다.
- 값 형태가 규격을 만족해야 함
- 키는 실제 정의된 것만 허용하고 싶음
- 각 값은 리터럴/구체 타입을 유지하면 좋음
type Handler = (payload: unknown) => Promise<void>;
type Handlers = Record<string, Handler>;
const handlers = {
userCreated: async (payload: { id: string }) => {
// ...
},
userDeleted: async (payload: { id: string; hard?: boolean }) => {
// ...
},
// userCreatd: ... 같은 오타는 여기서 잡히는 게 베스트
} satisfies Handlers;
type EventName = keyof typeof handlers; // "userCreated" | "userDeleted"
여기서 as Handlers로 단언하면 EventName을 잃고 string으로 퍼지기 쉽습니다. satisfies는 규격 검증과 키 보존을 동시에 합니다.
실무 패턴 2: 라이브러리 옵션 객체는 satisfies로 "형태만" 검증
예를 들어 Next.js나 서버 프레임워크의 옵션 객체는 버전업으로 옵션이 늘거나 바뀝니다. 이때
- 타입에 맞는지 검증하고
- 동시에 특정 옵션 값은 리터럴로 남겨서 분기 처리나 자동완성을 살리고 싶을 때
satisfies가 유리합니다.
type ImageOptions = {
formats: ("avif" | "webp")[];
deviceSizes: number[];
minimumCacheTTL: number;
};
const imageOptions = {
formats: ["avif", "webp"],
deviceSizes: [640, 750, 1080],
minimumCacheTTL: 60,
} satisfies ImageOptions;
Next.js App Router에서 이미지 최적화 같은 성능 튜닝을 할 때도 옵션을 잘못 넣으면 효과가 사라지거나 빌드 단계에서만 늦게 드러납니다. 관련 주제는 Next.js 14 App Router TTFB 줄이는 이미지 최적화도 함께 참고할 만합니다.
satisfies가 만능은 아니다: "타입을 바꾸지" 않는다
satisfies는 검증만 하고 타입을 바꾸지 않기 때문에, 어떤 경우에는 오히려 as나 명시적 타입 주석이 필요합니다.
예시: 인터페이스로 "고정"해서 넘겨야 하는 API
type RouteConfig = {
method: "GET" | "POST";
path: string;
};
function registerRoutes(r: Record<string, RouteConfig>) {
// ...
}
const routes = {
listUsers: { method: "GET", path: "/users" },
createUser: { method: "POST", path: "/users" },
} satisfies Record<string, RouteConfig>;
registerRoutes(routes); // 대부분의 경우 통과하지만,
// 상황에 따라 기대 타입이 더 넓거나 좁으면 추가 조정이 필요할 수 있음
이때 "나는 이 변수를 앞으로 Record<string, RouteConfig>로만 다루겠다"가 목적이라면 아래처럼 타입 주석이 더 명확합니다.
const routes2: Record<string, RouteConfig> = {
listUsers: { method: "GET", path: "/users" },
createUser: { method: "POST", path: "/users" },
};
정리하면
- 검증이 목적이면
satisfies - 해당 타입으로 고정해 API 경계에 맞추는 게 목적이면 타입 주석(또는 제한적으로
as)
이 더 적절합니다.
as를 써야 하는 경우: "불가피한" 경계에서만
as가 완전히 나쁜 것은 아닙니다. 다만 쓰임새를 좁혀야 합니다.
- 런타임 검증을 이미 마쳤고 타입체커가 표현 못 하는 경우
- 외부 입력을 스키마 검증 후에 좁힐 때
- DOM API, JSON 파싱 결과처럼 타입 정보가 없는 값을 다룰 때
예를 들어 런타임에서 스키마 검증을 통과한 뒤에는 as가 아니라도(타입 가드로) 좁힐 수 있지만, 비용이나 복잡도 때문에 단언이 필요한 경우가 있습니다.
type Payload = { id: string };
function isPayload(x: unknown): x is Payload {
return typeof x === "object" && x !== null && "id" in x && typeof (x as any).id === "string";
}
const raw: unknown = JSON.parse("{\"id\":\"abc\"}");
let payload: Payload;
if (isPayload(raw)) {
payload = raw; // 단언 없이 안전하게 좁힘
} else {
throw new Error("invalid payload");
}
이런 식으로 가능한 한 as를 "최후의 수단"으로 두는 게 좋습니다.
선택 가이드: 언제 무엇을 쓸까
satisfies를 우선 고려
- 객체 리터럴/설정/맵을 만들 때
- 오타, 누락, 잘못된 값 타입을 선언 지점에서 잡고 싶을 때
- 키 유니온과 리터럴 추론을 유지하고 싶을 때
타입 주석을 고려
- 변수 자체를 특정 타입으로 고정하고 싶을 때
- API 경계(함수 인자, export 타입)에서 명확성이 필요할 때
as는 제한적으로
- 런타임에서 이미 검증한 데이터
- TS가 표현하기 어려운 경우
- 레거시 코드에서 점진적 마이그레이션 중 임시 조치
마무리
TypeScript 5.x에서 satisfies는 "타입을 강제로 맞추는" 기능이 아니라 "요구사항을 만족하는지 검증하면서도 추론을 보존"하는 도구입니다. 특히 설정 객체와 맵 정의에서 as를 남용하면 오타와 누락이 조용히 통과해, 장애가 런타임에서야 드러나는 경우가 많습니다.
안전한 기본값은 다음 한 줄로 요약할 수 있습니다.
- 객체 리터럴을 정의할 때는
satisfies - 타입을 고정해야 할 때는 타입 주석
- 정말 불가피할 때만
as
이 기준으로 코드베이스의 타입 안정성과 개발 경험(자동완성, 리팩터링 내성)을 동시에 끌어올릴 수 있습니다.