Published on

TS 5.x satisfies로 타입 오류를 줄이는 실전 패턴

Authors

서론

TypeScript를 오래 쓰다 보면, “타입 안전하게 만들려고 했는데 오히려 타입이 뭉개져서(너무 넓어져서) IDE 자동완성도 줄고, 결국 as를 남발하게 되는” 순간이 자주 옵니다. 특히 설정 객체(config), 라우트/이벤트 테이블, 상수 맵 같은 객체 리터럴을 다룰 때가 그렇습니다.

TS 4.9부터(5.x에서도 동일하게) 제공되는 satisfies는 이 문제를 정면으로 해결합니다.

  • : Type 주석(annotation)은 값을 Type으로 강제하면서 추론 정보를 잃기 쉽고
  • as Type 단언(assertion)은 검증 없이 덮어씌우기 때문에 런타임 버그를 숨길 수 있는데
  • satisfies Type“이 값이 Type을 만족하는지”만 검사하고, 값 자체의 구체적인 추론 타입은 유지합니다.

이 글에서는 satisfies가 왜 타입 오류를 줄이는지, 그리고 실무에서 어떤 패턴으로 쓰면 좋은지 코드로 정리합니다.

satisfies가 해결하는 핵심 문제

1) 타입 주석(:)이 추론을 망가뜨리는 경우

다음은 흔한 패턴입니다. Record<string, ...>로 설정 맵을 선언해두면 깔끔해 보이지만, 키가 전부 string으로 넓어져 버립니다.

type Handler = (payload: unknown) => void;

const handlers: Record<string, Handler> = {
  userCreated: (payload) => {
    // payload는 unknown
  },
  orderPaid: (payload) => {
    // payload는 unknown
  },
};

// handlers.userCreated는 존재하지만,
// 키 자동완성/리터럴 유니온(userCreated | orderPaid)을 잃기 쉽다.

Record<string, ...>로 주석을 달면, 객체 리터럴의 "구체 키" 정보가 string으로 일반화되기 쉽습니다. 결과적으로 다른 코드에서 keyof typeof handlers를 써도 기대한 리터럴 유니온이 안 나오거나, 키 오타를 잡는 능력이 약해집니다.

2) 단언(as)이 오류를 숨기는 경우

type Env = "dev" | "prod";

const config = {
  env: "production", // 오타
} as { env: Env };

// 컴파일은 통과하지만 런타임에서 분기 로직이 깨질 수 있다.

as는 “내가 맞다고 할 테니 믿어”에 가깝습니다. 검증이 없으니 타입 오류를 줄이는 게 아니라 숨깁니다.

3) satisfies는 검증 + 추론 유지

type Env = "dev" | "prod";

type AppConfig = {
  env: Env;
  port: number;
};

const config = {
  env: "prod",
  port: 3000,
  // debug: true, // 필요하면 추가 가능(아래 섹션에서 설명)
} satisfies AppConfig;

// config.env는 "prod" (리터럴)로 유지
// config.port는 3000 (리터럴)로 유지

여기서 중요한 포인트는:

  • configAppConfig만족하는지 검사된다.
  • 하지만 config의 타입은 AppConfig로 “캐스팅”되지 않고, 리터럴 기반의 구체 타입이 유지된다.

즉, 타입 오류를 줄이면서도 DX(자동완성/리팩터링/정적 분석)를 더 좋게 만듭니다.

패턴 1: 설정 객체(config)에서 satisfies로 오타/누락 잡기

환경별 설정을 예로 들어봅시다.

type Stage = "local" | "dev" | "prod";

type StageConfig = {
  apiBaseUrl: string;
  timeoutMs: number;
};

const stageConfig = {
  local: { apiBaseUrl: "http://localhost:3000", timeoutMs: 3000 },
  dev: { apiBaseUrl: "https://dev.example.com", timeoutMs: 5000 },
  prod: { apiBaseUrl: "https://example.com", timeoutMs: 8000 },

  // prdo: { ... } // 오타가 있으면 아래 satisfies에서 잡힘
} satisfies Record<Stage, StageConfig>;

function getConfig(stage: Stage) {
  return stageConfig[stage];
}

장점:

  • Record<Stage, StageConfig>를 만족해야 하므로 local/dev/prod 누락 시 컴파일 에러
  • prdo 같은 오타 키는 “필요한 키가 없다”는 형태로 에러를 유도
  • 동시에 stageConfig.prod.timeoutMs 같은 접근에서 값이 리터럴로 유지되어, 조건 분기 최적화/자동완성에 유리

실무에서 이런 설정은 배포/인프라 장애와도 연결됩니다. 예를 들어 EKS에서 네트워크/인증 설정이 꼬여 장애가 날 때, 원인이 단순한 설정 키 오타/누락인 경우도 있습니다. 인프라 트러블슈팅 글을 참고해보면 설정 검증의 중요성이 더 체감됩니다: EKS TLS handshake timeout 해결 - IRSA·VPC·CoreDNS

패턴 2: 라우트/이벤트 맵에서 “키는 고정, 값은 유연”하게

라우팅 테이블이나 이벤트 핸들러 맵을 만들 때, 키는 제한하고 값은 각자 다른 형태를 갖습니다. 이때 satisfies가 특히 강력합니다.

type Routes = "/" | "/login" | "/settings";

type RouteMeta = {
  authRequired: boolean;
  title: string;
};

const routeMeta = {
  "/": { authRequired: false, title: "Home" },
  "/login": { authRequired: false, title: "Login" },
  "/settings": { authRequired: true, title: "Settings" },

  // "/setings": { ... } // 오타는 satisfies에서 잡힘
} satisfies Record<Routes, RouteMeta>;

// keyof typeof routeMeta는 정확히 Routes로 좁혀짐

이 방식은 다음을 동시에 만족합니다.

  • 키의 제한(오타 방지, 누락 방지)
  • 객체 리터럴의 구체 타입 유지(필요하면 더 정밀한 추론)

패턴 3: API 스키마/계약을 객체로 관리할 때

백엔드/외부 API 호출을 래핑할 때, 엔드포인트별로 메서드와 응답 타입을 매핑하는 패턴이 자주 나옵니다.

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

type EndpointSpec = {
  method: HttpMethod;
  path: string;
  timeoutMs?: number;
};

type EndpointName = "getUser" | "createPost";

const endpoints = {
  getUser: { method: "GET", path: "/v1/user", timeoutMs: 3000 },
  createPost: { method: "POST", path: "/v1/posts" },

  // createpost: { ... } // 오타
  // getUser: { method: "FETCH", path: "/v1/user" } // method 오타
} satisfies Record<EndpointName, EndpointSpec>;

function buildUrl(name: EndpointName) {
  return endpoints[name].path;
}

여기서 endpoints는 단지 타입을 “맞춘다” 수준이 아니라, 계약(Contract)을 코드로 고정합니다. 이 스타일은 OpenAI 같은 외부 API를 다룰 때도 유용합니다. 예를 들어 요청/응답 구조가 조금만 어긋나도 400이 터지는 환경에서는, 객체 기반 계약 검증이 큰 도움이 됩니다: OpenAI Responses API 400 invalid_tool_output 해결법

패턴 4: as const + satisfies 조합으로 리터럴을 끝까지 살리기

as const는 값을 최대한 리터럴로 고정하지만, 그 자체로는 “특정 타입을 만족하는지”를 강제하지 않습니다. 둘을 조합하면 다음이 가능합니다.

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

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

const loggerConfig = {
  level: "info",
  json: true,
} as const satisfies LoggerConfig;

// level은 "info"로 유지되면서, LogLevel에 속하는지도 검증됨

주의할 점:

  • as const는 객체의 속성을 readonly로 만들고 리터럴로 고정합니다.
  • 여기에 satisfies를 붙이면 “너무 빡빡하게 고정된 값”이 타입을 만족하는지까지 체크합니다.

설정값을 불변으로 다루고 싶은 코드베이스에서 특히 유용합니다.

패턴 5: satisfies로 “과한 일반화”를 피하고, 필요한 곳에서만 일반화

const x: SomeType = ...xSomeType으로 만들기 때문에, 이후 로직에서 더 구체적인 정보가 필요해도 사라져 버립니다. satisfies는 “검증만 하고 타입은 유지”하기 때문에, 필요할 때만 일반화할 수 있습니다.

type FeatureFlag = {
  enabled: boolean;
  rollout: number; // 0~100
};

const flags = {
  newCheckout: { enabled: true, rollout: 10 },
  newSearch: { enabled: false, rollout: 0 },
} satisfies Record<string, FeatureFlag>;

// flags는 구체 키(newCheckout, newSearch)를 유지
// 하지만 값은 FeatureFlag 형태를 강제

function isEnabled(name: keyof typeof flags) {
  return flags[name].enabled;
}

이 패턴은 점진적으로 기능 플래그가 늘어나는 서비스에서, 키 오타/값 누락을 줄이는 데 효과적입니다.

satisfies를 쓸 때 자주 하는 오해/주의점

1) “초과 속성(excess property)” 검사는 어떻게 되나?

객체 리터럴을 satisfies로 검사할 때도 기본적으로 타입 체크가 일어나며, 상황에 따라 초과 속성 관련 경고를 기대할 수 있습니다. 다만 satisfies는 “변수의 타입을 그 타입으로 바꾸지 않는다”는 점이 핵심이므로, 초과 속성을 엄격히 금지하는 설계를 원한다면 다음 중 하나를 고려하세요.

  • 타입 자체를 Exact 유틸로 강제(커스텀 타입)
  • 혹은 객체를 한 번 더 함수 인자로 넘겨 “정확성”을 검사하는 패턴

2) satisfies는 런타임 검증이 아니다

zod, io-ts처럼 런타임 스키마 검증을 해주지 않습니다. 외부 입력(JSON, API response)을 신뢰할 수 없다면 런타임 검증이 별도로 필요합니다.

3) 인덱스 시그니처/Record를 남발하면 키가 다시 넓어진다

Record<string, T>는 키를 string으로 넓히는 설계입니다. 키 유니온을 유지하려면 가능한 한 Record<"a"|"b", T>처럼 구체 키 유니온을 쓰거나, 객체 자체에서 keyof typeof obj를 활용하는 흐름으로 설계하세요.

결론: satisfies는 “안전한 검증”과 “좋은 추론”을 동시에 준다

TS 5.x에서 satisfies는 단순 문법 설탕이 아니라, 실무에서 타입 오류를 줄이는 강력한 도구입니다.

  • as를 줄이고(오류 은폐 방지)
  • 설정/맵/테이블에서 키 오타·누락을 빠르게 잡고
  • 객체 리터럴의 구체 타입 추론을 유지해 자동완성과 리팩터링 품질을 올립니다.

특히 “객체 리터럴을 많이 쓰는 코드베이스”일수록 효과가 큽니다. 인프라/플랫폼 레이어처럼 설정 실수가 장애로 이어질 수 있는 영역이라면 더더욱 그렇습니다. 관련 트러블슈팅 맥락이 궁금하다면 다음 글도 함께 보면 좋습니다: EKS Pod ImagePullBackOff 401 해결 가이드

다음 리팩터링 때부터는 : Type이나 as Type를 붙이기 전에, “이건 강제 캐스팅이 필요한가, 아니면 만족 여부만 확인하면 되는가?”를 먼저 떠올려 보세요. 그 질문의 답이 satisfies인 경우가 생각보다 많습니다.