Published on

TS 5.x satisfies vs as const - 타입 깨짐 해결

Authors

서버 설정, 라우트 테이블, 이벤트 핸들러 맵처럼 "값"에서 타입을 뽑아 쓰는 코드가 많아질수록, TypeScript에서 자주 만나는 문제가 있습니다.

  • Record<string, T> 같은 넓은 타입을 붙이는 순간 리터럴 타입이 string으로 깨짐
  • 반대로 as const를 남발하면 모든 것이 readonly로 굳어져 수정/합성이 어려워짐
  • 객체 리터럴의 오타나 누락을 잡고 싶지만, 추론도 유지하고 싶음

TS 5.x에서 satisfies는 이 딜레마를 꽤 우아하게 풀어줍니다. 하지만 satisfies가 만능은 아니고, as const가 더 적합한 경우도 명확히 존재합니다. 이 글에서는 두 문법의 차이를 “타입 깨짐” 관점에서 정리하고, 실무에서 바로 써먹을 수 있는 패턴을 코드로 보여드립니다.

관련 글로 satisfies 자체를 더 깊게 다룬 글도 있으니 함께 보면 좋습니다: TS 5.x satisfies로 타입추론 깨짐 해결하기

as constsatisfies의 핵심 차이

as const는 “값을 최대한 좁히고 고정”한다

as const는 리터럴을 최대한 좁은 타입으로 만들고, 객체/배열의 속성을 readonly로 고정합니다.

  • 장점: 값 기반 유니온 추출이 매우 강력함
  • 단점: readonly가 전파되어 이후 조작이 불편해짐
const STATUS = {
  ACTIVE: "active",
  INACTIVE: "inactive",
} as const;

type Status = (typeof STATUS)[keyof typeof STATUS];
// "active" | "inactive"

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

expr satisfies TypeexprType을 만족하는지 체크하지만, 결과 타입을 Type으로 강제 캐스팅하지 않습니다. 즉,

  • 타입 오류를 잡되
  • 원래의 더 구체적인(좁은) 추론을 유지
type StatusMap = Record<string, string>;

const STATUS = {
  ACTIVE: "active",
  INACTIVE: "inactive",
} satisfies StatusMap;

type Status = (typeof STATUS)[keyof typeof STATUS];
// "active" | "inactive"  (리터럴 유지)

여기서 중요한 포인트는 : StatusMap 주석 타입과의 차이입니다.

type StatusMap = Record<string, string>;

const STATUS_ANNOTATED: StatusMap = {
  ACTIVE: "active",
  INACTIVE: "inactive",
};

type StatusA = (typeof STATUS_ANNOTATED)[keyof typeof STATUS_ANNOTATED];
// string  (리터럴 깨짐)

: 타입 주석은 “이 변수는 앞으로 이 타입으로 취급할 거야”에 가깝고, satisfies는 “이 값이 이 조건을 만족하는지만 확인할게”에 가깝습니다.

타입 깨짐이 발생하는 대표 케이스

케이스 1: Record<string, ...>로 선언하는 순간 리터럴이 날아감

라우트/권한/이벤트 이름 맵을 만들 때 흔히 이렇게 씁니다.

type Route = {
  path: string;
  auth: "public" | "private";
};

type RouteTable = Record<string, Route>;

const ROUTES: RouteTable = {
  home: { path: "/", auth: "public" },
  admin: { path: "/admin", auth: "private" },
};

type RouteKey = keyof typeof ROUTES;
// string  (원래는 "home" | "admin"을 기대)

Record<string, ...>는 키를 string으로 강제하기 때문에 keyof typeof ROUTESstring이 됩니다. 이게 바로 “타입 깨짐”의 전형입니다.

해결: satisfies로 검증만 하고 키 유니온은 유지

type Route = {
  path: string;
  auth: "public" | "private";
};

type RouteTable = Record<string, Route>;

const ROUTES = {
  home: { path: "/", auth: "public" },
  admin: { path: "/admin", auth: "private" },
} satisfies RouteTable;

type RouteKey = keyof typeof ROUTES;
// "home" | "admin"

여기서 얻는 이점은 두 가지입니다.

  • 값이 Route를 만족하는지(예: auth 오타) 컴파일 타임에 잡음
  • 키 유니온이 유지되어 RouteKey가 정확해짐

케이스 2: 값 유니온을 뽑아 쓰는데 string으로 넓어짐

예를 들어 이벤트 이름을 객체로 관리하고 싶다고 합시다.

type EventNameMap = Record<string, string>;

const EVENTS: EventNameMap = {
  USER_CREATED: "user.created",
  USER_DELETED: "user.deleted",
};

type EventName = (typeof EVENTS)[keyof typeof EVENTS];
// string

이 경우 as const는 해결되지만 readonly가 따라옵니다.

const EVENTS = {
  USER_CREATED: "user.created",
  USER_DELETED: "user.deleted",
} as const;

type EventName = (typeof EVENTS)[keyof typeof EVENTS];
// "user.created" | "user.deleted"

해결: satisfies로 리터럴 유지 + 형태 검증

type EventNameMap = Record<string, string>;

const EVENTS = {
  USER_CREATED: "user.created",
  USER_DELETED: "user.deleted",
} satisfies EventNameMap;

type EventName = (typeof EVENTS)[keyof typeof EVENTS];
// "user.created" | "user.deleted"

이제 EVENTSreadonly로 얼지 않으면서도, 값 유니온은 살아 있습니다.

satisfies가 특히 강한 패턴 3가지

1) 객체 리터럴의 “오타/누락”을 잡으면서 추론 유지

as const만 쓰면 형태 검증이 약해질 수 있습니다. 예를 들어 아래에서 autn 같은 오타는 구조적 타입 체크가 없는 상황에서 놓치기 쉽습니다.

type Route = {
  path: string;
  auth: "public" | "private";
};

const ROUTES = {
  home: { path: "/", auth: "public" },
  // admin: { path: "/admin", autn: "private" }, // 오타
} satisfies Record<string, Route>;

ROUTES 내부 값이 Route를 만족해야 하므로 오타가 즉시 에러로 잡힙니다.

2) 배열 원소 타입을 검증하면서 리터럴을 유지

API 권한 스코프 목록 같은 것을 배열로 관리할 때:

type Scope = "read" | "write" | "admin";

const SCOPES = ["read", "write"] satisfies Scope[];
// 타입은 ("read" | "write")[] 로 추론되면서
// 동시에 원소가 Scope인지 검증됨

여기서 SCOPES의 원소 유니온을 뽑아 쓰고 싶다면:

type EnabledScope = (typeof SCOPES)[number];
// "read" | "write"

3) Record 키를 고정하고 값만 검증하기

키가 정해져 있고(예: 로케일), 값만 검증하고 싶을 때는 satisfies가 특히 깔끔합니다.

type Locale = "ko" | "en";

type Messages = {
  greeting: string;
  logout: string;
};

const I18N = {
  ko: { greeting: "안녕하세요", logout: "로그아웃" },
  en: { greeting: "Hello", logout: "Logout" },
} satisfies Record<Locale, Messages>;

type SupportedLocale = keyof typeof I18N;
// "ko" | "en"

만약 en을 빼먹거나 logout을 누락하면 컴파일 에러로 잡힙니다.

그래도 as const가 더 좋은 경우

1) “진짜 상수”로 취급되어야 하는 값

아래처럼 값이 변경되면 안 되고, 모든 속성이 readonly여야 안정적인 경우가 있습니다.

  • 액션 타입 상수
  • 프로토콜 상수
  • 변하면 안 되는 매직 넘버/문자열 테이블
const ACTION = {
  ADD: "ADD",
  REMOVE: "REMOVE",
} as const;

type Action = (typeof ACTION)[keyof typeof ACTION];

2) 깊은 레벨까지 리터럴 고정이 필요할 때

as const는 중첩 객체까지 readonly로 고정하고 리터럴을 최대한 좁힙니다.

const CONFIG = {
  feature: {
    enabled: true,
    mode: "safe",
  },
} as const;

// CONFIG.feature.mode 는 "safe" 리터럴

satisfies는 “검증”이 목적이므로, 깊은 레벨의 리터럴 고정이 목표라면 as const가 더 직관적입니다.

실무에서 추천하는 선택 기준

1) “검증 + 추론 유지”가 목적이면 satisfies

  • 객체 리터럴을 Record<string, ...>로 검증하고 싶은데 키 유니온을 잃기 싫다
  • 값 유니온을 뽑아 쓰고 싶다
  • 설정/맵/테이블에서 오타를 잡고 싶다

이때 satisfies가 가장 깔끔합니다.

2) “불변 상수”가 목적이면 as const

  • 수정되면 안 되는 상수 테이블
  • 깊은 레벨까지 리터럴과 readonly가 필요

3) 둘을 섞는 패턴도 가능

예를 들어 “불변이면서도 형태 검증”이 필요하면 다음처럼 조합할 수 있습니다.

type Route = {
  path: string;
  auth: "public" | "private";
};

const ROUTES = {
  home: { path: "/", auth: "public" },
  admin: { path: "/admin", auth: "private" },
} as const satisfies Record<string, Route>;

여기서 주의할 점은 as const로 인해 값이 readonly가 되므로, 이후에 ROUTES.admin.path = ... 같은 변경은 불가능합니다. 정말 상수로 다룰 때만 쓰는 것이 좋습니다.

자주 하는 실수와 디버깅 팁

실수 1: 타입 주석 : 로 추론을 스스로 죽임

const x: SomeType = ...는 “x를 SomeType으로 취급”합니다. 리터럴 추론이 필요할 때는 satisfies를 우선 고려하세요.

실수 2: satisfies를 썼는데도 타입이 넓어짐

대개는 목표 타입이 너무 넓게 정의되어 있거나, 이미 한 번 넓혀진 값을 다시 검증하고 있는 경우입니다. 예를 들어 중간 변수에 Record<string, string>을 붙여버리면 그 순간 리터럴이 깨졌고, 이후 satisfies로는 복구가 안 됩니다.

type M = Record<string, string>;

const tmp: M = { A: "a", B: "b" }; // 여기서 이미 string으로 넓어짐
const obj = tmp satisfies M;
// obj의 값 리터럴은 돌아오지 않음

해결은 “처음 객체 리터럴을 만드는 지점”에서 바로 satisfies를 적용하는 것입니다.

마무리

  • as const는 “리터럴 최대한 좁힘 + 깊은 불변”에 강하고
  • satisfies는 “형태 검증 + 추론 유지”에 강합니다.

특히 TS 5.x에서 satisfiesRecord 기반 설정/맵에서 키 유니온과 값 리터럴을 살려주기 때문에, 타입 깨짐을 체감적으로 크게 줄여줍니다.

추론이 깨지는 지점이 : 타입 주석인지, Record<string, ...> 같은 넓은 타입 강제인지부터 확인하고, “검증만 필요”한 곳은 satisfies로 바꿔보면 대부분의 문제가 정리됩니다.