Published on

TS 5.x satisfies로 타입 깨짐 잡는 법

Authors

서버/프론트 코드에서 설정 객체, 라우트 테이블, 권한 맵, 이벤트 핸들러 레지스트리처럼 “큰 객체 리터럴”을 자주 만듭니다. 이때 흔히 겪는 문제가 두 가지입니다.

  1. 타입을 강하게 걸면 추론이 죽고(리터럴이 넓어지고)
  2. 추론을 살리면 검증이 약해져서 깨진 타입이 런타임까지 숨어 들어갑니다.

TypeScript 5.x의 satisfies는 이 딜레마를 꽤 깔끔하게 해결합니다. 핵심은 “검증은 하되, 타입을 강제 캐스팅하지 않는다” 입니다.

아래에서 as, 타입 어노테이션, satisfies의 차이를 비교하고, 실무에서 타입 깨짐을 조기에 잡는 패턴을 예제로 정리합니다.

as / 타입 어노테이션이 만드는 함정

1) as SomeType는 검증이 아니라 “우기기”다

as는 타입 시스템에 “이 값은 이 타입이 맞다”고 강제로 믿게 합니다. 그래서 객체 리터럴이 틀려도 컴파일이 통과할 수 있습니다.

type User = {
  id: string;
  role: "admin" | "user";
};

// 실수: id를 number로 넣었지만 as로 덮어버림
const u = { id: 123, role: "admin" } as User;
// 컴파일 통과, 런타임에서 문제로 이어질 수 있음

2) 타입 어노테이션은 검증은 되지만 추론이 넓어진다

객체에 타입을 직접 붙이면 초과 속성 검사(excess property check)는 잘 되지만, 리터럴 타입이 종종 넓어져서 이후 로직이 불편해집니다.

type Routes = Record<string, { method: "GET" | "POST"; path: string }>;

const routes: Routes = {
  health: { method: "GET", path: "/health" },
  // ...
};

// routes.health.method 의 타입은 "GET" | "POST" 로 넓어짐

여기서 routes.health.method가 실제로는 항상 "GET"인데도 타입이 넓어지면, 분기 최적화나 매핑 타입에서 “정확한 리터럴”을 활용하기가 어려워집니다.

satisfies의 핵심: 검증 + 추론 유지

expr satisfies T는 다음을 동시에 만족합니다.

  • exprT만족하는지 검사한다 (틀리면 컴파일 에러)
  • 하지만 expr 자체의 타입은 원래 추론된 타입을 유지한다

즉, “T로 캐스팅”하지 않고 “T를 만족하는지 검증”만 합니다.

type Routes = Record<string, { method: "GET" | "POST"; path: string }>;

const routes = {
  health: { method: "GET", path: "/health" },
  login: { method: "POST", path: "/login" },
} satisfies Routes;

// routes.health.method 는 "GET" 으로 유지(리터럴 유지)

이 차이가 실무에서 꽤 큽니다. 라우트, 이벤트, 권한 같은 “정적 테이블”을 선언할 때 satisfies는 타입 깨짐을 잡으면서도, 테이블을 기반으로 한 파생 타입을 더 정확하게 만들 수 있습니다.

패턴 1: 설정 객체에서 오타·누락을 조기에 잡기

환경별 설정이나 기능 플래그는 보통 객체 리터럴로 관리합니다. 여기서 satisfies는 다음을 잡는 데 강합니다.

  • 필수 키 누락
  • 값 타입 오류
  • (상황에 따라) 불필요한 키 추가
type AppConfig = {
  env: "dev" | "prod";
  apiBaseUrl: string;
  retry: {
    maxAttempts: number;
    backoffMs: number;
  };
};

const config = {
  env: "prod",
  apiBaseUrl: "https://api.example.com",
  retry: {
    maxAttempts: 5,
    backoffMs: 200,
  },
  // typo: apiBaseURL 같은 오타 키를 넣으면 잡고 싶다
} satisfies AppConfig;

여기서 중요한 포인트는 config.env"prod"로 유지되는 등 리터럴 추론이 살아있다는 점입니다. 이후 if (config.env === "prod") 같은 분기에서 더 정밀한 타입 흐름을 얻을 때 도움이 됩니다.

패턴 2: 권한/역할 매트릭스에서 “키 누락”을 강제하기

권한 매트릭스는 보통 “역할별 허용 작업”처럼 생깁니다. 이때 자주 깨지는 타입은 역할 키 누락입니다.

type Role = "admin" | "manager" | "viewer";

type PermissionMatrix = Record<Role, {
  canRead: boolean;
  canWrite: boolean;
  canDelete: boolean;
}>;

const permissions = {
  admin: { canRead: true, canWrite: true, canDelete: true },
  manager: { canRead: true, canWrite: true, canDelete: false },
  viewer: { canRead: true, canWrite: false, canDelete: false },
} satisfies PermissionMatrix;

만약 viewer를 빼먹으면 바로 컴파일 에러가 납니다. 반대로 as PermissionMatrix로 덮으면 누락이 숨어버릴 수 있습니다.

파생 타입 만들기: 리터럴 유지의 장점

permissions가 리터럴을 유지하므로, 아래처럼 “삭제 가능한 역할만 뽑기” 같은 타입 연산이 더 정확해집니다.

type RolesThatCanDelete = {
  [R in keyof typeof permissions]: typeof permissions[R]["canDelete"] extends true ? R : never
}[keyof typeof permissions];

// RolesThatCanDelete 는 "admin" 으로 좁혀짐

패턴 3: 라우트 테이블에서 핸들러 시그니처 깨짐 잡기

Next.js나 Express 스타일로 라우트 핸들러를 맵으로 관리할 때, 핸들러의 입력/출력 타입이 살짝만 어긋나도 런타임 오류로 이어집니다.

아래는 “정해진 라우트 키”와 “핸들러 시그니처”를 강제하는 예입니다.

type RouteKey = "health" | "login";

type Handler<Req, Res> = (req: Req) = Promise<Res>;

type RouteSpec = {
  health: Handler<{ traceId: string }, { ok: true }>;
  login: Handler<{ username: string; password: string }, { token: string }>;
};

const handlers = {
  health: async (req: { traceId: string }) = ({ ok: true as const }),

  // 실수: password 누락
  login: async (req: { username: string }) = ({ token: "t" }),
} satisfies RouteSpec;

위 코드는 login의 요청 타입이 스펙과 다르므로 컴파일 단계에서 바로 깨집니다.

여기서도 handlers 자체는 리터럴/함수 시그니처 추론을 유지하므로, keyof typeof handlers 같은 파생 타입이 깔끔합니다.

패턴 4: 이벤트/커맨드 레지스트리에서 철자 실수 방지

이벤트 이름을 문자열로 흩뿌리면 오타가 늘어납니다. satisfies는 “이 레지스트리가 특정 이벤트 집합을 모두 포함한다”를 강제하는 데 유용합니다.

type EventName = "USER_CREATED" | "USER_DELETED";

type EventPayloads = {
  USER_CREATED: { id: string };
  USER_DELETED: { id: string; reason?: string };
};

type EventHandlers = {
  [E in EventName]: (payload: EventPayloads[E]) = void;
};

const eventHandlers = {
  USER_CREATED: (p: { id: string }) = {
    // ...
  },
  USER_DELETED: (p: { id: string; reason?: string }) = {
    // ...
  },
} satisfies EventHandlers;

EventName에 이벤트를 추가했는데 eventHandlers에 핸들러를 추가하지 않으면 즉시 깨집니다. “새 기능 추가 시 누락 방지”에 특히 강합니다.

satisfies 사용 시 자주 묻는 포인트

1) 초과 속성 검사는 어떻게 되나

객체 리터럴에 대해 satisfies를 쓰면 기본적으로 “지정한 타입을 만족하는지”를 보므로, 타입이 허용하지 않는 키를 넣으면 에러가 납니다.

다만 대상 타입이 인덱스 시그니처(Record<string, ...>)처럼 “아무 키나 허용”하면 초과 속성 검사의 의미가 약해집니다. 이 경우에는 키 집합을 더 구체적으로 만들거나, as const와 함께 키를 고정하는 전략이 필요합니다.

2) as const와의 관계

  • as const: 값을 최대한 리터럴/readonly로 고정
  • satisfies: 특정 타입을 만족하는지 검증

둘은 경쟁 관계가 아니라 조합 관계입니다.

type HttpMethod = "GET" | "POST";

type Endpoint = {
  method: HttpMethod;
  path: string;
};

const endpoints = {
  health: { method: "GET", path: "/health" },
  login: { method: "POST", path: "/login" },
} as const satisfies Record<string, Endpoint>;

이 조합은 “값은 리터럴로 꽉 잡고, 형태는 스펙을 만족하는지 검증”하는 전형적인 패턴입니다.

3) 언제 satisfies가 특히 효과적인가

  • 설정/상수 테이블이 크고 자주 바뀐다
  • 키 누락이 장애로 이어질 수 있다
  • 객체에서 파생 타입을 만들고 싶다 (keyof typeof, 매핑 타입 등)
  • as 캐스팅을 줄이고 싶다

운영에서 장애를 줄이는 관점에서는, 이런 “정적 테이블”의 타입 깨짐을 컴파일 단계에서 잡는 게 가장 비용 효율이 좋습니다. 장애 원인 추적이 어렵고 재현이 힘든 케이스일수록 사전에 걸러야 합니다. 비슷한 맥락으로 운영 이슈를 빠르게 진단하는 글로는 Next.js 14 ISR 캐시가 안 갱신될 때 원인·해결, 인프라 레벨에서의 장애 추적은 K8s CrashLoopBackOff와 OOMKilled 원인 추적도 참고할 만합니다.

실전 체크리스트: 타입 깨짐을 줄이는 선언 스타일

1) “스펙 타입”과 “값”을 분리한다

  • 스펙: type RouteSpec = { ... }
  • 값: const routes = { ... } satisfies RouteSpec

이렇게 하면 스펙 변경 시 깨지는 지점이 선명합니다.

2) as SomeType는 마지막 수단으로 둔다

외부 라이브러리 타입이 부정확하거나, 점진적 마이그레이션 중이라면 as가 필요할 수 있습니다. 하지만 내부 상수/테이블에는 되도록 satisfies를 우선 적용하세요.

3) 파생 타입을 적극적으로 만든다

typeof routes 기반으로 키/입력/출력 타입을 뽑아 쓰면 문자열 상수 오타가 줄고, 리팩터링이 쉬워집니다.

const routes = {
  health: { method: "GET", path: "/health" },
  login: { method: "POST", path: "/login" },
} satisfies Record<string, { method: "GET" | "POST"; path: string }>;

type RouteName = keyof typeof routes;

function buildUrl(name: RouteName) {
  return routes[name].path;
}

마무리

TypeScript에서 타입이 깨지는 순간은 대개 “값은 맞는 것 같은데 타입이 미묘하게 어긋난” 상태로 시작합니다. 특히 설정/레지스트리/테이블 형태의 객체는 한 번 틀어지면 영향 범위가 넓습니다.

satisfies는 이런 객체 리터럴에 대해 **검증(안전)**과 **추론 유지(유연)**를 동시에 가져옵니다. 기존에

  • as로 덮어두고 불안했던 코드
  • 타입 어노테이션 때문에 리터럴 정보가 날아가던 코드

가 있다면, 해당 지점부터 satisfies로 바꿔보는 것만으로도 타입 깨짐을 상당히 줄일 수 있습니다.