Published on

TS 5.5+ satisfies로 타입추론 깨짐 해결하기

Authors

서버/프론트 공통으로 TypeScript를 쓰다 보면 “분명히 이 값은 리터럴로 유지돼야 하는데 왜 string으로 넓어졌지?” 같은 타입 추론 붕괴를 자주 만납니다. 특히 객체 리터럴을 변수에 담는 순간, 혹은 레지스트리(맵) 형태로 핸들러를 모으는 순간, TypeScript가 안전을 위해 타입을 넓혀 버리면서 이후 코드에서 자동완성/리팩터링/오류 탐지가 약해집니다.

TypeScript 5.x에서 널리 쓰이기 시작한 satisfies는 이런 문제를 해결하는 데 매우 강력합니다. 핵심은 간단합니다.

  • : SomeType 같은 명시적 타입 주석은 값의 구체성을 잃게(리터럴 widening) 만들 수 있음
  • as SomeType 같은 단언은 검증을 건너뛰어 위험
  • satisfies SomeType검증은 하되, 값의 구체 타입은 그대로 유지

이 글은 TS 5.5+ 환경에서 satisfies를 중심으로 “타입추론 깨짐”을 실제로 복구하는 패턴을 정리합니다.

왜 타입추론이 깨질까: widening과 구조적 타이핑의 합작

TypeScript는 구조적 타입 시스템이고, 객체/배열 리터럴은 문맥에 따라 타입이 달라집니다. 대표적으로 아래 상황에서 리터럴이 쉽게 넓어집니다.

  1. 변수에 담기면서 문맥이 사라짐
  2. : Interface로 타입을 “맞춰 넣으면서” 리터럴 정보가 손실
  3. Record<string, ...> 같은 넓은 인덱스 시그니처가 리터럴 키를 string으로 밀어버림
  4. 제네릭 함수에 잘못된 타입 인자를 주거나, 반환 타입을 과하게 일반화

이 문제는 런타임 버그를 직접 만들기도 하지만, 더 흔한 피해는 타입 기반 리팩터링의 힘이 약해지는 것입니다. 예를 들어 라우트/이벤트/액션 이름이 string으로 넓어지는 순간, 오타를 컴파일 타임에 못 잡습니다.

satisfies가 해결하는 것: “검증”과 “타입 유지”의 분리

const x: SomeType = ...는 “x는 SomeType이다”를 선언합니다. 그 과정에서 리터럴 타입이 SomeType에 맞게 변환/확장될 수 있습니다.

반면 const x = ... satisfies SomeType는 이렇게 동작합니다.

  • x의 타입은 오로지 값으로부터 추론된 타입을 유지
  • 동시에 그 값이 SomeType만족하는지 체크

즉, “타입을 강요”하지 않고 “타입을 검증”합니다.

패턴 1: 설정 객체에서 리터럴 추론을 살리고 제약만 걸기

가장 흔한 케이스는 설정 객체입니다. 예를 들어 로깅 레벨은 특정 문자열만 허용하고, 옵션은 선택적으로 두고 싶습니다.

나쁜 예: 타입 주석으로 리터럴이 넓어짐

type LogLevel = "debug" | "info" | "warn" | "error";

type LoggerConfig = {
  level: LogLevel;
  json: boolean;
};

const config: LoggerConfig = {
  level: "debug",
  json: true,
};

// 여기서 config.level은 LogLevel로 보이지만
// 다른 파생 타입을 만들 때 리터럴("debug") 정보가 이미 사라져 활용이 어려워짐

좋은 예: satisfies로 제약만 걸고 리터럴 유지

type LogLevel = "debug" | "info" | "warn" | "error";

type LoggerConfig = {
  level: LogLevel;
  json: boolean;
};

const config = {
  level: "debug",
  json: true,
} satisfies LoggerConfig;

// config.level은 "debug"로 유지되고
// 동시에 LoggerConfig를 만족하는지 검증됨

이 상태에서 typeof config.level"debug"가 되어, “현재 설정이 debug일 때만 허용되는 동작” 같은 파생 타입을 만들기 쉬워집니다.

패턴 2: 레지스트리(핸들러 맵)에서 키/값 타입을 동시에 지키기

API 라우트, 이벤트 핸들러, 커맨드 패턴 등에서 아래처럼 “이름-함수” 맵을 자주 만듭니다.

문제: Record<string, ...>가 키를 string으로 밀어버림

type Handler = (payload: unknown) => Promise<unknown>;

const handlers: Record<string, Handler> = {
  createUser: async (payload) => {
    return { ok: true };
  },
  deleteUser: async (payload) => {
    return { ok: true };
  },
};

// keyof typeof handlers 는 string
// 즉 "createUser" | "deleteUser" 를 잃어버림

이러면 아래 같은 코드가 약해집니다.

  • dispatch("cretaeUser", ...) 같은 오타를 못 잡음
  • 핸들러 추가/삭제 시 호출부 리팩터링이 어려움

해결: satisfies로 “모든 값은 Handler여야 한다”만 강제

type Handler = (payload: unknown) => Promise<unknown>;

type HandlerMap = Record<string, Handler>;

const handlers = {
  createUser: async (payload: unknown) => {
    return { ok: true };
  },
  deleteUser: async (payload: unknown) => {
    return { ok: true };
  },
} satisfies HandlerMap;

type HandlerName = keyof typeof handlers;
// "createUser" | "deleteUser"

async function dispatch(name: HandlerName, payload: unknown) {
  return handlers[name](payload);
}

포인트는 handlers의 타입을 HandlerMap으로 “고정”하지 않고, “만족하는지”만 확인했다는 점입니다. 그래서 키는 리터럴 유니온으로 남고, 값은 Handler 시그니처를 만족해야만 컴파일이 통과합니다.

패턴 3: as const와의 조합으로 “값은 고정, 타입은 검증” 만들기

satisfies는 리터럴을 유지하지만, 중첩 객체/배열에서 “읽기 전용 리터럴”까지 원하면 as const와 조합하는 경우가 많습니다.

예를 들어 기능 플래그 목록을 “수정 불가한 상수”로 두고, 동시에 특정 스키마를 만족시키고 싶을 때입니다.

type Feature = {
  key: string;
  owner: "frontend" | "backend";
  enabledByDefault: boolean;
};

type FeatureList = readonly Feature[];

const features = [
  { key: "new-nav", owner: "frontend", enabledByDefault: false },
  { key: "fast-sync", owner: "backend", enabledByDefault: true },
] as const satisfies FeatureList;

// features[0].owner 는 "frontend" 로 유지
// 동시에 owner가 허용된 값인지 검증됨

주의할 점은 as const만 쓰면 “검증”이 아니라 “고정”만 됩니다. 즉, owner: "mobile" 같은 실수가 들어가도 타입을 적절히 설계하지 않으면 놓칠 수 있습니다. satisfies를 붙이면 스키마 검증까지 함께 가져갈 수 있습니다.

패턴 4: 유니온 기반 스키마에서 “과잉 일반화” 막기

이벤트 시스템을 예로 들면, 이벤트 이름에 따라 payload 형태가 달라지는 경우가 많습니다.

type EventSchema = {
  userCreated: { id: string; email: string };
  userDeleted: { id: string };
};

type EventName = keyof EventSchema;

type Emit = <N extends EventName>(
  name: N,
  payload: EventSchema[N]
) => void;

여기서 이벤트 핸들러를 등록하는 맵을 만들 때, 타입을 잘못 주면 payload가 any처럼 넓어지거나, 반대로 모든 핸들러가 동일 payload로 강제되기도 합니다.

satisfies를 활용하면 각 핸들러의 구체 타입을 유지하면서 전체 제약을 걸 수 있습니다.

type EventSchema = {
  userCreated: { id: string; email: string };
  userDeleted: { id: string };
};

type Handlers = {
  [K in keyof EventSchema]: (payload: EventSchema[K]) => void;
};

const handlers = {
  userCreated: (payload) => {
    payload.email;
  },
  userDeleted: (payload) => {
    payload.id;
  },
} satisfies Handlers;

// 오타/누락/잘못된 payload 접근을 컴파일 타임에 잡음

이 패턴은 “이름-스키마-핸들러”가 함께 진화하는 코드베이스에서 특히 강력합니다.

satisfies vs 타입 주석 vs 단언: 언제 무엇을 쓰나

: Type (타입 주석)

  • 장점: 변수 타입을 명확히 고정
  • 단점: 리터럴 정보가 사라질 수 있고, 이후 파생 타입 설계가 어려워질 수 있음
  • 추천: 외부에 노출되는 API 경계(함수 파라미터/리턴 타입), 의도적으로 일반화하고 싶을 때

as Type (타입 단언)

  • 장점: 강제로 통과
  • 단점: 검증을 우회하므로 런타임 버그 가능성 증가
  • 추천: 정말로 컴파일러가 표현을 못 하는 극히 제한적인 케이스에만 사용

satisfies Type

  • 장점: 검증은 하되 값의 구체 타입은 유지
  • 단점: “변수 자체의 타입을 Type으로 고정”하진 않음(그게 목적이 아님)
  • 추천: 설정 객체, 레지스트리/맵, 상수 테이블, 스키마 기반 핸들러에서 기본값으로 고려

TS 5.5+에서 특히 체감이 큰 지점

TS 5.5 자체가 satisfies 문법을 새로 도입한 버전은 아니지만, 5.x로 오면서 코드베이스가 커질수록 다음 니즈가 커집니다.

  • 객체 리터럴을 더 많이 “데이터로” 다루고(설정/테이블/레지스트리)
  • 타입은 더 강하게 “스키마로” 관리하려는 경향이 생기며
  • 그 사이에서 리터럴 추론이 깨지면 생산성이 급격히 떨어짐

이때 satisfies는 “스키마 검증”과 “리터럴 보존”을 동시에 만족시키는 도구라, TS 5.5+로 올리면서 타입 품질을 한 단계 끌어올리는 데 도움이 됩니다.

실전 팁: satisfies 적용 체크리스트

  1. 객체를 Record<string, ...>로 적고 있고 keyofstring이 되어버렸다면 satisfies를 우선 고려
  2. 설정 객체에 : ConfigType을 붙였더니 특정 필드가 리터럴이 아니라 유니온/원시 타입으로 넓어진다면 satisfies로 교체
  3. as const만으로 고정해 두고 “스키마 검증”이 빠져 있다면 as const satisfies SomeSchema 조합 검토
  4. 외부로 노출되는 함수/모듈 경계는 여전히 반환 타입/파라미터 타입을 명시하고, 내부 상수/테이블에서 satisfies로 안전하게 추론을 살리는 식으로 역할 분리

마무리: 타입추론이 깨질수록 satisfies의 가치가 커진다

TypeScript에서 타입 추론은 생산성의 핵심이지만, 규모가 커질수록 “추론이 넓어지는 순간”이 잦아지고 그때마다 타입 주석과 단언 사이에서 고민하게 됩니다. satisfies는 그 중간 지대를 깔끔하게 메워줍니다.

  • 타입을 강요하지 않고 검증한다
  • 리터럴 타입을 보존해 keyof, 디스패치, 스키마 기반 매핑이 강해진다
  • 레지스트리/설정/상수 테이블 같은 실전 코드에서 효과가 즉각적이다

프론트 성능 최적화처럼 “작은 병목이 전체 체감에 큰 영향”을 주는 영역이 있듯이, 타입 시스템에서도 이런 작은 패턴이 DX를 크게 바꿉니다. Next.js 기반 프로젝트라면 타입 안정성이 곧 리팩터링 속도로 이어지니, 관련해서는 Next.js App Router 로딩 느림? RSC 캐시·prefetch 최적화처럼 구조를 다듬는 글과 함께 보면 효과가 더 큽니다.

또한 팀 개발에서는 타입 안정성이 CI 품질로 직결됩니다. 배포 파이프라인에서 권한/설정이 한 번 꼬이면 생산성이 크게 떨어지듯, 타입도 한 번 넓어지기 시작하면 오타가 숨어들기 쉽습니다. 그런 관점에서 GitHub Actions OIDC로 AWS 권한 오류 해결하기 같은 “검증을 자동화하는 습관”과 결이 같습니다.

마지막으로, satisfies를 도입할 때 목표는 ‘모든 곳에 쓰기’가 아니라 **추론이 깨지는 경계(객체/맵/테이블)**에 정확히 배치하는 것입니다. 이 몇 군데만 바로잡아도 TS 5.5+의 타입 추론을 훨씬 믿고 개발할 수 있습니다.