Published on

TS 5.x satisfies로 타입추론 깨짐 해결하기

Authors
Binance registration banner

서론

TypeScript를 오래 쓰다 보면 “타입은 맞는데 추론이 죽어서” 생산성이 떨어지는 순간을 자주 만납니다. 대표적으로 설정 객체나 매핑 테이블을 만들 때 : SomeType 같은 타입 주석을 붙이는 순간, 값이 가진 구체적인 리터럴 정보가 넓은 타입으로 확장(widening)되면서 자동완성, 분기 좁히기, 키 추론 등이 급격히 나빠집니다.

TS 4.9부터 도입되고 TS 5.x에서 사실상 정착한 satisfies는 이 문제를 꽤 우아하게 해결합니다. 핵심은 “이 값이 어떤 타입 조건을 만족하는지 검사하되, 값 자체의 추론 타입은 바꾸지 않는다”는 점입니다. 즉, 타입 안전성(검증)과 구체적 추론(리터럴 보존)을 동시에 가져갈 수 있습니다.

이 글에서는 satisfies가 해결하는 “타입추론 깨짐”의 전형적인 패턴과, TS 5.x에서 실전적으로 적용하는 방법을 코드 중심으로 정리합니다.

타입추론이 깨지는 전형적인 패턴

문제 1: 타입 주석이 리터럴을 넓혀버림

설정 객체를 만들 때 흔히 이렇게 씁니다.

type Env = "dev" | "prod";

type AppConfig = {
  env: Env;
  apiBaseUrl: string;
  retry: {
    max: number;
    backoffMs: number;
  };
};

const config: AppConfig = {
  env: "dev",
  apiBaseUrl: "https://example.test",
  retry: {
    max: 3,
    backoffMs: 200,
  },
};

여기서 config.envEnv로, config.retry.maxnumber로 보입니다. 타입 자체는 맞지만, 값이 실제로는 "dev", 3 같은 구체값이라는 정보가 사라졌습니다. 이게 항상 나쁜 건 아니지만, 다음과 같은 경우 DX가 급격히 나빠집니다.

  • config.env가 실제로는 "dev"일 때만 가능한 분기 최적화/좁히기
  • 매핑 테이블에서 키/값을 리터럴로 유지해야 하는 경우
  • as const를 쓰기엔 타입 검증이 부족한 경우

문제 2: as const만 쓰면 “검증”이 없다

리터럴을 보존하려고 as const를 붙이면 추론은 좋아집니다.

const config = {
  env: "dev",
  apiBaseUrl: "https://example.test",
  retry: {
    max: 3,
    backoffMs: 200,
  },
} as const;

하지만 이건 AppConfig를 만족하는지 보장하지 않습니다. 예를 들어 retry.max를 실수로 문자열로 써도, 별도의 타입 검사가 없다면 놓치기 쉽습니다(물론 사용하는 지점에서 터질 수 있지만, “정의 시점”에 잡히는 게 가장 좋습니다).

satisfies의 핵심: 검증은 하되 추론은 유지

value satisfies Type는 다음을 동시에 수행합니다.

  • 컴파일 타임에 valueType을 만족하는지 검사
  • 그러나 value 변수의 타입은 Type으로 “고정”하지 않고, 값에서 추론된 타입을 유지

: 타입 주석과 달리, 타입을 덮어씌우지 않습니다.

예제: 설정 객체에서 추론 보존

type Env = "dev" | "prod";

type AppConfig = {
  env: Env;
  apiBaseUrl: string;
  retry: {
    max: number;
    backoffMs: number;
  };
};

const config = {
  env: "dev",
  apiBaseUrl: "https://example.test",
  retry: {
    max: 3,
    backoffMs: 200,
  },
} satisfies AppConfig;

// 추론 관점
// config.env: "dev"  (리터럴 유지)
// config.retry.max: 3 (리터럴 유지)
// 동시에 AppConfig 형태 검증도 통과해야 함

이제 config는 “구체적인 값”을 유지하면서도, AppConfig의 구조/타입 요구사항을 만족해야만 컴파일이 됩니다.

satisfies가 특히 강력한 실전 패턴

1) 매핑 테이블: 키/값 리터럴을 살린 채로 타입 검증

예를 들어 에러 코드를 메시지로 매핑한다고 합시다.

type ErrorCode = "E_AUTH" | "E_TIMEOUT" | "E_UNKNOWN";

type ErrorMessageMap = Record<ErrorCode, string>;

const errorMessages = {
  E_AUTH: "로그인이 필요합니다.",
  E_TIMEOUT: "요청 시간이 초과되었습니다.",
  E_UNKNOWN: "알 수 없는 오류입니다.",
} satisfies ErrorMessageMap;

function getErrorMessage(code: ErrorCode) {
  return errorMessages[code];
}

여기서 얻는 이점은 다음과 같습니다.

  • ErrorCode에 새 값이 추가되면 매핑 누락을 즉시 컴파일 에러로 감지
  • errorMessages 객체 자체는 리터럴 기반으로 유지되어, 다른 파생 타입을 만들기 쉬움

예를 들어 키 목록이 필요하면:

type ErrorMessageKey = keyof typeof errorMessages;
// "E_AUTH" | "E_TIMEOUT" | "E_UNKNOWN"

2) 유니온 기반 “정책 객체”에서 좁히기 개선

정책(Policy) 같은 객체를 만들 때, 타입 주석을 붙이면 kind가 넓어져서 좁히기가 불편해지는 경우가 있습니다.

type Policy =
  | { kind: "fixed"; value: number }
  | { kind: "percent"; value: number };

const discountPolicy = {
  kind: "percent",
  value: 10,
} satisfies Policy;

function applyDiscount(price: number) {
  if (discountPolicy.kind === "fixed") {
    // 여기서 discountPolicy.kind는 "percent"로 이미 결정되어 있어서
    // 사실상 dead branch가 될 수 있고, 추론도 더 구체적
    return price - discountPolicy.value;
  }
  return price * (1 - discountPolicy.value / 100);
}

물론 위 예시는 상수라서 극단적이지만, 라우트 정의/이벤트 정의처럼 “선언부에서 리터럴을 최대한 살리고 싶은” 케이스에서 satisfies가 특히 유용합니다.

3) 라우트/이벤트 정의: 자동완성과 안전성 동시 확보

예를 들어 이벤트 이름과 페이로드 타입을 같이 관리한다고 합시다.

type EventSpec = {
  [eventName: string]: (payload: unknown) => void;
};

type AppEvents = {
  "user:login": (payload: { userId: string }) => void;
  "user:logout": (payload: { reason?: string }) => void;
};

const handlers = {
  "user:login": (payload: { userId: string }) => {
    console.log(payload.userId);
  },
  "user:logout": (payload: { reason?: string }) => {
    console.log(payload.reason);
  },
} satisfies AppEvents;

function emit<E extends keyof AppEvents>(
  event: E,
  payload: Parameters<AppEvents[E]>[0],
) {
  handlers[event](payload);
}

emit("user:login", { userId: "u_1" });
// emit("user:login", { userId: 123 }); // 컴파일 에러

여기서 handlers: AppEvents = ...로 선언해도 되지 않냐는 질문이 나오는데, 그 방식은 종종 핸들러 구현에서 불필요하게 타입이 넓어지거나(특히 리터럴/튜플/readonly 추론), 후속 파생 타입 생성에서 손해를 봅니다. satisfies는 “정의는 자유롭게, 검증은 엄격하게” 가져가게 해줍니다.

satisfiesas const의 관계: 같이 쓰면 더 강해짐

as const는 리터럴 및 readonly를 강제합니다. satisfies는 구조를 검증합니다. 둘은 목적이 다르므로 함께 쓰는 조합이 자주 등장합니다.

예를 들어 HTTP 상태 코드를 상수로 관리하면서, 값이 특정 범위 타입을 만족하길 원한다고 합시다.

type HttpStatus = 200 | 201 | 400 | 401 | 403 | 404 | 500;

type StatusMap = Record<string, HttpStatus>;

const STATUS = {
  OK: 200,
  CREATED: 201,
  BAD_REQUEST: 400,
  UNAUTHORIZED: 401,
  FORBIDDEN: 403,
  NOT_FOUND: 404,
  INTERNAL: 500,
} as const satisfies StatusMap;

type StatusKey = keyof typeof STATUS;
// "OK" | "CREATED" | ...

type StatusValue = (typeof STATUS)[StatusKey];
// 200 | 201 | 400 | 401 | 403 | 404 | 500

이 패턴의 장점:

  • as const로 키/값을 최대한 구체화
  • satisfies로 “값이 HttpStatus 집합 안에 있는지”를 선언 시점에 강제

“타입추론 깨짐”을 어떻게 진단할까

: 타입 주석을 붙였더니 추론이 망가졌다면, 보통 다음 증상이 같이 옵니다.

  • keyof typeof obj가 기대보다 넓어짐(예: string으로 변함)
  • 값이 리터럴이 아니라 string/number로만 보임
  • obj.someKey 접근 시 자동완성이 줄어듦
  • 제네릭 함수에 넘겼을 때 타입 인자가 의도치 않게 넓게 추론됨

이때 우선순위는 보통 다음과 같습니다.

  1. “타입을 덮어씌우는” 선언(const x: T = ...)을 “검증만 하는” 선언(const x = ... satisfies T)로 바꿔보기
  2. 리터럴/튜플/readonly가 필요하면 as const를 추가하고, 그 위에 satisfies로 검증하기
  3. 그래도 안 되면 타입 설계를 조정(인덱스 시그니처 남용, 너무 넓은 Record<string, ...> 등)

satisfies 사용 시 주의점

1) satisfies는 타입을 “변환”하지 않는다

satisfies는 캐스팅이 아닙니다. 즉 런타임 변화도 없고, 타입을 억지로 맞추는 것도 아닙니다. 타입이 안 맞으면 그냥 에러가 납니다.

type Conf = { port: number };

const bad = {
  port: "3000",
} satisfies Conf;
// 컴파일 에러: string은 number에 할당 불가

2) 너무 넓은 목표 타입을 주면 효과가 반감

예를 들어 아래는 검증이 거의 의미가 없습니다.

const x = {
  a: 1,
  b: 2,
} satisfies Record<string, number>;

키가 무엇이든 허용되므로 누락/오타를 잡기 어렵습니다. 이런 경우엔 가능한 한 구체적인 유니온 키나 정확한 객체 타입을 목표로 두는 게 좋습니다.

3) “초과 속성 검사” 기대치 조정

객체 리터럴을 어떤 타입에 할당할 때 발생하는 초과 속성 검사(excess property checks)는 문맥에 따라 체감이 다를 수 있습니다. satisfies는 “만족 여부”를 검사하지만, 목표 타입이 인덱스 시그니처를 포함하거나 너무 넓으면 초과 속성에 관대한 형태가 됩니다. 결론적으로 satisfies만 믿기보다 목표 타입을 잘 설계해야 합니다.

TS 5.x에서 추천하는 적용 전략

1) “선언부”에 satisfies를 우선 적용

  • 설정 객체
  • 라우트 정의
  • 이벤트 스펙
  • 에러 코드/메시지 테이블
  • 권한/역할 매트릭스

이런 선언부는 “한 번 정의하고 여러 군데서 파생 타입을 만드는” 경우가 많습니다. 이때 추론 보존이 곧 DX로 직결됩니다.

2) 함수 인자 레벨에서는 신중하게

함수 인자에서 satisfies를 남발하면 가독성이 떨어질 수 있습니다. 보통은 선언부 상수에 적용하고, 함수 시그니처는 제네릭/오버로드로 정리하는 편이 유지보수에 유리합니다.

3) CI에서 타입체크를 빠르게 유지하기

satisfies를 도입하면 선언부 타입 검증이 촘촘해져서 타입체크가 더 많은 파일을 따라가게 될 수 있습니다. 대규모 모노레포에서는 CI 캐시/빌드 전략이 함께 중요해집니다. 타입체크가 느려져서 개발 경험이 흔들린다면 아래 글들도 같이 참고할 만합니다.

마무리

TS 5.x에서 satisfies는 “타입 안전성과 타입추론을 동시에 잡는” 가장 실용적인 도구 중 하나입니다. 기존에 const x: T = ... 패턴을 습관적으로 쓰고 있었다면, 특히 선언부 상수/테이블/스펙 정의에서 satisfies로 바꿔보는 것만으로도 자동완성과 파생 타입 설계가 눈에 띄게 좋아집니다.

정리하면 다음 한 줄로 귀결됩니다.

  • 타입을 “강제”해서 추론을 죽이지 말고, 타입을 “검증”해서 추론을 살리자: satisfies.