Published on

TS 5.x satisfies vs as - 타입오류·추론 차이

Authors

서로 비슷해 보이는 assatisfies는 실제로 목적이 다릅니다. as는 개발자가 타입을 "우겨 넣는"(assert) 수단이고, satisfies는 값이 특정 타입의 요구사항을 "충족하는지"(check) 검증하되 값 자체의 더 구체적인 타입 추론은 유지하는 수단입니다.

TypeScript 5.x에서 satisfies가 널리 쓰이기 시작한 이유는 간단합니다. 객체 리터럴, 설정 객체, 라우팅 테이블, 이벤트 맵 같은 곳에서

  • 타입 안정성은 확보하고
  • 동시에 자동완성과 리터럴 추론은 최대한 살리고
  • 불필요한 타입 단언으로 오류를 숨기지 않기

를 동시에 만족시키기 때문입니다.

아래에서 assatisfies타입 오류를 내는 지점, 추론 결과, 실무 패턴에서 어떻게 달라지는지 정리합니다.

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>다"로 고정됩니다.
    • 키가 실제로 listUserscreateUser뿐이라는 정보가 약해질 수 있고
    • 내부 프로퍼티도 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

이 기준으로 코드베이스의 타입 안정성과 개발 경험(자동완성, 리팩터링 내성)을 동시에 끌어올릴 수 있습니다.