Published on

TS 5.x satisfies로 타입 좁히기 실무 패턴

Authors

서버/프론트 공통으로 TypeScript를 쓰다 보면 “타입을 엄격하게 검증하고 싶은데, 동시에 추론은 최대한 살리고 싶다”는 요구가 자주 충돌합니다. 예를 들어 설정 객체를 Record<string, ...>로 강제하면 값의 리터럴 정보가 사라져서 이후 코드에서 분기 최적화(타입 좁히기)가 어려워지고, 반대로 as const만 남발하면 검증이 약해져 오타/누락이 런타임까지 흘러갑니다.

TS 5.x의 satisfies는 이 간극을 메우는 도구입니다. 핵심은 “해당 값이 어떤 타입을 만족(satisfy) 하는지 컴파일 타임에 검증하되, 값 자체의 추론 타입은 최대한 유지한다”는 점입니다.

이 글에서는 satisfies를 타입 좁히기에 연결하는 실무 패턴을 집중적으로 다룹니다. 특히 설정/라우팅/핸들러 디스패치/스키마 정의 같은 곳에서 효과가 큽니다.

참고로, 타입 검증을 강화하면 API 응답/스트리밍 처리에서 재시도나 중복 토큰 처리 같은 로직도 더 안전해집니다. 관련해서는 OpenAI SSE 스트리밍 끊김·중복 토큰 재시도 패턴 글도 함께 보면 좋습니다.

satisfies가 해결하는 문제: “검증”과 “추론”의 분리

먼저 전형적인 함정을 보겠습니다.

: 타입 주석의 함정(추론 손실)

type Env = "dev" | "prod";

type Config = {
  env: Env;
  logLevel: "debug" | "info" | "warn" | "error";
};

// 타입 주석을 달면, 객체 전체가 Config로 '맞춰져' 추론됩니다.
const config: Config = {
  env: "dev",
  logLevel: "debug",
};

// 여기서 config.env는 Env로만 보이므로
// "dev" 분기에서 더 강한 좁히기를 기대하기 어렵습니다.

config.env는 실제로는 "dev"인데, 타입은 Env로 넓어집니다. 이후 if (config.env === "dev") 같은 분기에서 타입 시스템이 얻을 수 있는 정보가 줄어듭니다.

as const만 쓰면 생기는 문제(검증 약함)

const config = {
  env: "dev",
  logLevel: "debug",
  // typo: "inf" 같은 값도 as const만으로는 구조 검증이 약할 수 있음
} as const;

as const는 리터럴을 보존하지만 “이 객체가 우리가 기대한 타입을 만족하는지”를 강제하진 않습니다(물론 초과 속성/유니온 값 오류는 잡히지만, 구조적 요구를 더 명시적으로 하고 싶을 때가 많습니다).

satisfies의 포인트

type Config = {
  env: "dev" | "prod";
  logLevel: "debug" | "info" | "warn" | "error";
};

const config = {
  env: "dev",
  logLevel: "debug",
} satisfies Config;

// config.env 타입은 "dev"로 유지(추론 보존)
// 동시에 Config를 만족하는지 검증(타입 안전)

즉, satisfies는 “이 값은 Config 규격을 만족해야 한다”를 걸어두면서도, 값의 리터럴 타입을 그대로 유지합니다.

패턴 1) 설정 객체: 리터럴 유지 + 스위치 분기 좁히기

실무에서 가장 흔한 케이스는 환경별 옵션/피처 플래그입니다.

type Mode = "dev" | "staging" | "prod";

type FeatureFlags = {
  newCheckout: boolean;
  verboseMetrics: boolean;
};

type AppConfig = {
  mode: Mode;
  flags: FeatureFlags;
  apiBaseUrl: string;
};

const appConfig = {
  mode: "prod",
  flags: {
    newCheckout: true,
    verboseMetrics: false,
  },
  apiBaseUrl: "https://api.example.com",
} satisfies AppConfig;

// mode는 "prod"로 유지되므로 분기에서 더 강하게 좁혀짐
function init() {
  if (appConfig.mode === "prod") {
    // 여기서는 appConfig.mode가 "prod"
  }
}

실무 팁: “검증 타입”은 넓게, “값”은 좁게

  • AppConfig는 조직 표준 스키마(넓은 규격)
  • appConfig는 배포 단위의 실제 값(좁은 리터럴)

이 구분이 생기면, 코드가 자연스럽게 “규격 위반은 컴파일 타임에 차단”되고 “실제 값 기반 분기 최적화는 타입 좁히기로 지원”됩니다.

패턴 2) 핸들러 맵: 키 누락/오타 방지 + 안전한 디스패치

HTTP 라우팅, 이벤트 타입 디스패치, 작업 큐 핸들러에서 Record를 자주 씁니다. 문제는 Record를 타입 주석으로 박아버리면 값의 세부 타입이 뭉개지기 쉽다는 점입니다.

이벤트 디스패치 예시

type Event =
  | { type: "user.created"; userId: string }
  | { type: "user.deleted"; userId: string }
  | { type: "invoice.paid"; invoiceId: string; amount: number };

type EventType = Event["type"];

type HandlerMap = {
  [K in EventType]: (event: Extract<Event, { type: K }>) => Promise<void>;
};

const handlers = {
  "user.created": async (event) => {
    // event는 { type: "user.created"; userId: string }
    console.log(event.userId);
  },
  "user.deleted": async (event) => {
    console.log(event.userId);
  },
  "invoice.paid": async (event) => {
    console.log(event.invoiceId, event.amount);
  },
} satisfies HandlerMap;

async function dispatch(event: Event) {
  // event.type은 런타임 값
  await handlers[event.type](event as never);
}

위의 dispatch에서 event as never가 거슬릴 수 있습니다. 이건 TS가 인덱싱 후 호출 시점에 “각 키별로 다른 함수 시그니처”를 완전히 연결하지 못해서 생깁니다.

실무 개선: 타입 가드 결합

function isEventType<T extends EventType>(
  type: EventType,
  expected: T
): type is T {
  return type === expected;
}

async function dispatch(event: Event) {
  if (isEventType(event.type, "invoice.paid")) {
    // event는 invoice.paid로 좁혀짐
    await handlers["invoice.paid"](event);
    return;
  }

  await handlers[event.type](event as never);
}

핵심은 satisfies가 “핸들러 맵의 누락/오타”를 강하게 막아주고, 타입 가드/분기와 결합하면 실제 호출부의 안전성도 끌어올릴 수 있다는 점입니다.

패턴 3) as const와 함께 쓰기: 값은 고정, 스키마는 검증

as const는 여전히 유용합니다. 특히 배열/튜플/리터럴 맵에서 값 자체를 불변 리터럴로 만들고 싶을 때가 많습니다.

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

type RolePolicy = {
  canWrite: boolean;
  canDelete: boolean;
};

type PolicyMap = Record<Role, RolePolicy>;

const policies = {
  admin: { canWrite: true, canDelete: true },
  member: { canWrite: true, canDelete: false },
  guest: { canWrite: false, canDelete: false },
} as const satisfies PolicyMap;

// policies.admin.canDelete는 true로 유지(리터럴)

이 조합의 장점:

  • as const로 값은 최대한 좁게(리터럴)
  • satisfies로 구조는 반드시 맞게(누락/오타/타입 불일치 차단)

패턴 4) “허용되지만 금지해야 하는 키”를 막기: satisfies + never

설정 객체에서 “이 키는 절대 들어오면 안 된다” 같은 규칙이 있습니다. 예를 들어 서버 전용 옵션이 브라우저 번들 설정에 섞이면 사고가 납니다.

type BrowserConfig = {
  apiBaseUrl: string;
  // serverOnlyKey는 브라우저 설정에 들어오면 안 됨
  serverOnlyKey?: never;
};

const browserConfig = {
  apiBaseUrl: "/api",
  // serverOnlyKey: "secret", // 여기서 컴파일 에러
} satisfies BrowserConfig;

never를 이용한 금지 필드는 실무에서 꽤 강력합니다. 특히 모노레포에서 서버/클라 설정이 비슷한 모양일 때, 실수로 섞이는 걸 컴파일 타임에 막을 수 있습니다.

패턴 5) 라우트 정의: 경로-메서드-응답 타입을 한 번에 검증

API 클라이언트/서버를 동시에 TS로 관리할 때, 라우트 메타데이터를 객체로 들고 있는 경우가 많습니다.

type HttpMethod = "GET" | "POST" | "DELETE";

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

type Routes = {
  health: RouteDef;
  createInvoice: RouteDef;
  deleteUser: RouteDef;
};

const routes = {
  health: { method: "GET", path: "/health", auth: "public" },
  createInvoice: { method: "POST", path: "/invoices", auth: "user" },
  deleteUser: { method: "DELETE", path: "/users/:id", auth: "admin" },
} satisfies Routes;

// routes.health.method는 "GET"으로 유지

여기서 얻는 실무적 이득:

  • 라우트 키 누락/오타를 즉시 발견
  • 메서드/권한 같은 분기 로직에서 리터럴 기반 타입 좁히기 가능
  • 코드 생성/문서화/테스트 픽스처에도 그대로 재사용 가능

이런 “정의 객체 단일 소스” 패턴은 JSON Schema 기반의 엄격한 출력 검증과도 잘 맞습니다. LLM 출력처럼 스키마 준수가 중요한 영역은 OpenAI Structured Outputs 400 해결 - JSON Schema 글과 함께 보면 맥락이 이어집니다.

패턴 6) satisfies로 유니온을 ‘만족’시키되, 값은 더 구체적으로

유니온 타입을 강제하면 보통 값이 유니온으로 넓어집니다. satisfies는 반대로 “유니온에 들어갈 수 있는지”만 확인하고, 실제 값은 더 구체적으로 남겨둡니다.

type StorageConfig =
  | { driver: "s3"; bucket: string; region: string }
  | { driver: "local"; baseDir: string };

const storage = {
  driver: "s3",
  bucket: "my-bucket",
  region: "ap-northeast-2",
} satisfies StorageConfig;

// storage.driver는 "s3"로 유지
if (storage.driver === "s3") {
  // bucket, region 접근이 자연스럽게 안전
  console.log(storage.bucket, storage.region);
}

타입 좁히기 관점에서 중요한 포인트는, 이후 분기에서 driver가 리터럴로 남아 있어야 “해당 분기에서 필요한 필드가 존재한다”는 추론이 강해진다는 점입니다.

satisfies 사용 시 주의점(실무에서 자주 밟는 지뢰)

1) satisfies는 타입 캐스팅이 아니다

as SomeType은 강제로 우겨 넣을 수 있지만, satisfies는 “만족하지 않으면 에러”입니다. 즉, 안전장치입니다.

type C = { port: number };

const c = {
  port: "3000", // 컴파일 에러
} satisfies C;

2) 중첩 객체에서의 검증 범위를 의식하기

const x = { ... } satisfies Type는 전체 구조를 검증합니다. 하지만 중첩 객체를 별도 변수로 뽑아두면 그 지점에서 추론/검증이 달라질 수 있습니다. 실무에서는 “검증 경계”를 어디로 둘지 정하는 게 중요합니다.

3) 인덱스 시그니처와의 상호작용

Record<string, ...> 같은 인덱스 시그니처는 지나치게 넓어져서 오히려 오타를 놓칠 수 있습니다. 가능하면 키를 유니온으로 만들거나 as const로 키를 고정하고 satisfies로 검증하는 쪽이 유지보수에 유리합니다.

결론: satisfies는 “정의 객체”의 품질을 올리는 도구

TS 5.x의 satisfies는 단순 문법 설탕이 아니라, 실무에서 자주 쓰는 “정의 객체(설정, 라우트, 정책, 핸들러 맵)”의 품질을 크게 끌어올립니다.

정리하면 다음 전략이 가장 재현성이 좋습니다.

  • 객체 리터럴은 satisfies로 스키마를 검증한다
  • 값의 리터럴 타입은 유지해 분기/디스패치에서 타입 좁히기를 극대화한다
  • 필요하면 as const satisfies ... 조합으로 불변 리터럴과 구조 검증을 동시에 가져간다
  • 금지 필드는 never로 차단해 “섞이면 사고” 나는 옵션을 컴파일 타임에 막는다

이 패턴들을 프로젝트의 설정/라우팅/이벤트 처리 레이어에 적용하면, 런타임 버그가 “배포 후”가 아니라 “커밋 전에” 사라지는 경험을 하게 됩니다.