Published on

TS 5.x satisfies vs as const 타입추론 실전

Authors

서로 비슷해 보이지만 satisfiesas const는 의도와 결과가 꽤 다릅니다. 둘 다 “타입을 더 정확하게 만들고 싶다”는 요구에서 출발하지만, 한쪽은 검증에 가깝고 다른 한쪽은 고정(리터럴/readonly화) 에 가깝습니다. TS 5.x에서 satisfies가 널리 쓰이기 시작하면서, 기존에 as const로 억지로 해결하던 패턴을 더 안전하고 읽기 좋게 바꿀 수 있게 됐습니다.

이 글은 다음을 목표로 합니다.

  • satisfiesas const의 핵심 차이를 “타입 추론 결과” 관점에서 이해
  • 설정 객체, 맵, 라우트, 이벤트 정의 등 실무에서 바로 쓰는 패턴 제시
  • 둘을 섞어 쓰는 베스트 프랙티스와 흔한 함정 정리

참고로 Node 런타임/번들러 환경에서 TS 설정을 다루다 보면 모듈 시스템 이슈도 자주 만나는데, ESM 전환 관련해서는 Node.js 22에서 require가 깨질 때 ESM 전환도 같이 보면 좋습니다.

as const는 “값을 얼리고 타입을 좁힌다”

as const는 표현식을 가능한 한 좁은 타입으로 만들고, 객체/배열이면 readonly로 바꿉니다.

const roles = ["admin", "member", "guest"] as const;
// 타입: readonly ["admin", "member", "guest"]

type Role = (typeof roles)[number];
// "admin" | "member" | "guest"

객체에서도 동일합니다.

const config = {
  env: "prod",
  retry: 3,
  features: {
    newCheckout: true,
  },
} as const;

// config.env 타입은 "prod"
// config.retry 타입은 3
// config.features.newCheckout 타입은 true
// 그리고 전부 readonly

as const의 장점

  • 리터럴 유니온을 쉽게 만들 수 있음
  • 키/값을 “정확히” 고정해 타입 레벨에서 재사용 가능
  • 라우트 목록, 이벤트 이름 목록 같은 상수 정의에 매우 강력

as const의 단점(실무에서 자주 터짐)

  • 숫자/불리언까지 리터럴로 고정되어 “너무 좁아짐”
  • readonly가 걸려서 이후 가공 로직에서 불편함
  • “이 값이 특정 타입을 만족하는지” 검증이 아니라 “그냥 단언(assert)”이라서, 잘못된 형태도 as로 밀어붙이면 통과시킬 수 있음

as const는 “타입 안정성 검사”가 아니라 “타입을 이렇게 봐줘”에 가깝습니다.

satisfies는 “검증하되, 원래의 추론은 유지한다”

TS 4.9부터 도입된 satisfies는 TS 5.x에서 사실상 표준 패턴이 됐습니다. 핵심은 다음 한 줄입니다.

  • satisfies타입 체크를 수행하지만, 값의 추론 타입을 그 타입으로 강제하지 않는다

예시로 감을 잡아봅시다.

type AppConfig = {
  env: "dev" | "stage" | "prod";
  retry: number;
  features: Record<string, boolean>;
};

const config = {
  env: "prod",
  retry: 3,
  features: {
    newCheckout: true,
  },
} satisfies AppConfig;

여기서 중요한 포인트:

  • configAppConfig만족하는지 검사됩니다.
  • 동시에 config.env는 여전히 리터럴로 추론될 수 있습니다(상황에 따라 다르지만, “강제로 AppConfig로 캐스팅”하는 것보다 훨씬 자연스럽게 구체성이 유지됩니다).
  • 무엇보다 as AppConfig와 달리, 필드 누락/오타/타입 불일치가 있으면 제대로 에러를 냅니다.

as AppConfigsatisfies AppConfig의 차이

type AppConfig = {
  env: "dev" | "stage" | "prod";
  retry: number;
};

// 1) 단언: 위험
const a = {
  env: "prod",
  retry: "3",
} as AppConfig;
// 컴파일러가 믿어버릴 수 있음(상황에 따라 경고 없이 넘어감)

// 2) 만족: 안전
const b = {
  env: "prod",
  retry: "3",
} satisfies AppConfig;
// 에러: retry는 number여야 함

as는 개발자가 “내가 책임질게”라고 선언하는 것이고, satisfies는 컴파일러에게 “이 규격을 만족하는지 검사해줘”라고 요청하는 것입니다.

실전 패턴 1: 키는 고정, 값은 유연한 맵 만들기

실무에서 제일 많이 나오는 케이스가 “키는 특정 집합이어야 하고, 값은 타입만 맞으면 된다”입니다.

예: 권한별 라벨 맵

type Role = "admin" | "member" | "guest";

type RoleLabelMap = Record<Role, string>;

const roleLabels = {
  admin: "관리자",
  member: "멤버",
  guest: "게스트",
} satisfies RoleLabelMap;

// 오타나 누락이 있으면 즉시 에러
// 예: guets: "게스트"  // 에러
// 예: guest 누락          // 에러

여기서 as const를 쓸 필요가 없습니다. 라벨 문자열을 리터럴로 고정할 이유가 없다면, satisfies가 더 적합합니다.

반대로 라벨을 리터럴로 “재사용”하고 싶다면

const roleLabels = {
  admin: "관리자",
  member: "멤버",
  guest: "게스트",
} as const satisfies Record<"admin" | "member" | "guest", string>;

type AdminLabel = (typeof roleLabels)["admin"]; // "관리자"

이 패턴은 “형태 검증 + 값 리터럴 보존”을 동시에 가져갑니다.

실전 패턴 2: 라우트 정의에서 satisfies로 누락/오타 잡기

라우트 테이블을 상수로 정의할 때, as const만 쓰면 “형태 검증”이 약해질 수 있습니다.

type RouteDef = {
  path: string;
  auth: "public" | "user" | "admin";
};

type RouteKey = "home" | "login" | "admin";

type Routes = Record<RouteKey, RouteDef>;

const routes = {
  home: { path: "/", auth: "public" },
  login: { path: "/login", auth: "public" },
  admin: { path: "/admin", auth: "admin" },
} satisfies Routes;

장점:

  • RouteKey에 정의된 키가 빠지거나 추가되면 에러
  • auth 값이 허용된 유니온이 아니면 에러
  • path를 실수로 숫자로 넣는 등 타입 불일치 즉시 검출

라우트처럼 “배포 전에 CI에서 잡아야 하는 실수”는 satisfies가 특히 유용합니다. CI 최적화는 별개 주제지만, 타입 체크가 느려지는 팀이라면 GitHub Actions 매트릭스로 CI 시간 50% 줄이기처럼 파이프라인도 같이 손보면 체감이 큽니다.

실전 패턴 3: 이벤트 이름은 리터럴, 핸들러 시그니처는 검증

프론트/백엔드 모두에서 이벤트 기반 구조를 쓰면 “이벤트 이름-페이로드 타입 맵”을 자주 만듭니다.

type EventMap = {
  "user.created": { id: string; email: string };
  "user.deleted": { id: string };
};

type HandlerMap = {
  [K in keyof EventMap]: (payload: EventMap[K]) => Promise<void>;
};

const handlers = {
  "user.created": async (payload) => {
    payload.email.toLowerCase();
  },
  "user.deleted": async (payload) => {
    payload.id;
  },
} satisfies HandlerMap;

여기서 얻는 효과:

  • 키("user.created") 오타가 있으면 에러
  • 핸들러 파라미터 타입이 자동 추론되며, 잘못된 필드 접근이 즉시 에러
  • handlers 객체의 “추론 타입”은 과도하게 넓어지지 않고, 작성한 함수 시그니처도 자연스럽게 유지

as const로 이벤트 키를 고정하고 싶다면 보통 이벤트 이름 목록을 따로 뽑을 때입니다.

const eventNames = ["user.created", "user.deleted"] as const;
type EventName = (typeof eventNames)[number];

이렇게 “이름 목록”은 as const, “이름별 구현체 검증”은 satisfies로 역할을 분리하는 게 깔끔합니다.

실전 패턴 4: as const가 과하게 좁혀서 생기는 문제와 해결

as const를 설정 객체에 무심코 붙였다가, 숫자/불리언이 리터럴로 고정되어 곤란해지는 일이 흔합니다.

const retryPolicy = {
  maxRetries: 3,
  backoffMs: 200,
} as const;

function withRetry(maxRetries: number) {
  return maxRetries;
}

withRetry(retryPolicy.maxRetries);
// 타입은 3이라서 number 자리에 들어가긴 하지만,
// 이후 연산/조합에서 "3"이라는 지나치게 좁은 타입이 전파될 수 있음

이런 경우는 satisfies가 더 자연스럽습니다.

type RetryPolicy = {
  maxRetries: number;
  backoffMs: number;
};

const retryPolicy = {
  maxRetries: 3,
  backoffMs: 200,
} satisfies RetryPolicy;

정리하면:

  • “값을 리터럴로 박제할 이유가 없다”면 as const를 붙이지 않는 편이 낫습니다.
  • “형태만 검증하고, 값은 자연스럽게 쓰겠다”면 satisfies가 적합합니다.

실전 패턴 5: satisfies로 열거형 대체하기

TS에서 enum을 피하고 문자열 리터럴 유니온으로 대체하는 팀이 많습니다. 이때 satisfies가 좋은 접착제가 됩니다.

const Status = {
  Pending: "pending",
  Success: "success",
  Failed: "failed",
} as const;

type Status = (typeof Status)[keyof typeof Status];
// "pending" | "success" | "failed"

여기에 “정해진 포맷을 만족해야 한다” 같은 제약을 걸고 싶다면 satisfies를 얹을 수 있습니다.

type LowercaseWord = `${string}`; // 예시: 실전에서는 더 구체적인 템플릿을 쓰기도 함

type StatusShape = Record<string, LowercaseWord>;

const Status = {
  Pending: "pending",
  Success: "success",
  Failed: "failed",
} as const satisfies StatusShape;

포인트는 satisfies가 “검증 레이어”로 동작한다는 점입니다.

언제 무엇을 써야 하나: 선택 기준 체크리스트

as const를 우선 고려

  • 배열/객체를 리터럴 유니온으로 뽑아내야 함
  • 키/값 자체를 타입 레벨에서 재사용할 예정
  • 변경 불가능(readonly) 구조가 오히려 안전함

예: 라우트 키 목록, 이벤트 이름 목록, 국가 코드 목록, UI variant 목록

satisfies를 우선 고려

  • 객체가 특정 인터페이스/레코드 형태를 “만족”해야 함
  • 키 누락/오타를 컴파일 타임에 잡고 싶음
  • 값 리터럴 고정이나 readonly가 불필요하거나 방해됨
  • as SomeType 단언을 없애고 싶음

예: 설정 객체, 권한-라벨 맵, 이벤트 핸들러 맵, 의존성 주입 레지스트리

둘을 같이 쓰는 경우

  • “값은 리터럴로 보존”하면서 “형태도 검증”하고 싶다
const permissions = {
  admin: ["read", "write", "delete"],
  member: ["read", "write"],
  guest: ["read"],
} as const satisfies Record<string, readonly string[]>;

이 패턴은 특히 권한/피처 플래그처럼 “상수로 박아두되, 구조적 제약도 강하게” 가져가고 싶을 때 좋습니다.

흔한 함정 3가지

1) satisfies는 타입을 “바꾸지” 않는다

const x = value satisfies Tx의 타입을 T로 만드는 문법이 아닙니다. “검사만” 합니다. 따라서 T로 고정된 타입이 필요하면 별도의 타입 주석이 필요할 수 있습니다.

type Config = { env: "dev" | "prod" };

const raw = { env: "prod" } satisfies Config;
// raw 타입은 작성한 값 기반으로 추론됨

const cfg: Config = raw;
// 여기서 Config로 고정 가능

2) as const는 깊게 readonly가 된다

객체 내부까지 readonly가 전파되므로, 이후에 값을 조립/수정하는 코드가 있으면 불편해집니다. 이때는 “상수 정의 레이어”와 “런타임 조립 레이어”를 분리하는 게 좋습니다.

3) as 단언으로 에러를 덮지 말 것

as는 정말 필요할 때만 쓰고, 대부분은 satisfies로 대체하는 편이 유지보수에 유리합니다. 특히 설정/인프라 값처럼 운영 장애로 이어질 수 있는 영역은 “검증”이 중요합니다.

마무리: TS 5.x에서의 권장 조합

  • 상수 목록, 리터럴 유니온이 목적이면 as const
  • 객체가 어떤 스펙을 만족해야 한다면 satisfies
  • 둘 다 필요하면 as const satisfies SomeShape로 “리터럴 보존 + 구조 검증”

TS 5.x 코드베이스에서 as SomeType이 여기저기 보인다면, 그중 상당수는 satisfies로 바꾸는 것만으로도 타입 안정성이 올라가고(특히 오타/누락), 추론 품질도 좋아지는 경우가 많습니다. 한 번에 전부 바꾸기 어렵다면, 라우트/이벤트/권한 맵처럼 “정적 정의 객체”부터 적용해보는 걸 추천합니다.