본문 바로가기
About IT/기술 및 언어

Satisfies 키워드 파헤치기 : 검증은 엄격하게, 추론은 풍부하게

by yjin_fe 2026. 4. 1.

들어가며.

비교적 최근에 있었던 기술 과제 기반의 면접 세션에서 이런 질문을 받았습니다.

"잘 만들어둔 상수 맵이 있는데, 새로운 타입이 추가되면 누락을 컴파일 타임에 강제할 방법이 있나요?"

 

해당 질문에 대한 답은 코드에 따라 여러 방식이 있을 겁니다. 아쉽게도 저는 질문에 명확한 답을 제시하지 못했습니다. 이후 피드백에서 'satisfies'를 활용하면 이런 누락을 컴파일 타임에 잡을 수 있다는 힌트를 받았고, 그 키워드를 실제 코드에 적용하면서 공부한 내용을 정리해보았습니다.

 

 

Satisfies 는.

satisfies 키워드는 TypeScript 4.9(2022-11)에 추가된 연산자입니다. 한 줄로 요약하면 이렇습니다.

"이 값이 어떤 타입을 만족하는지만 검증하되, 값 자체의 추론 타입은 그대로 유지한다."

 

문법은 단순합니다.

const value = expression satisfies TargetType;

 

 

여기서 satisfies가 하는 일은 'expression이 TargetType에 대입 가능(assignable)한지 검사'하는 것입니다.

단순히 '검사'만 하기 때문에, value의 실제 타입은 TargetType이 아니라 expression에서 추론된 가장 구체적인 타입이 됩니다.  타입 어노테이션 (: TargetType)처럼 value의 타입을 고정하는 것이 아니라, TargetType에 할당 가능한지만 검증한다는 점이 핵심입니다.

 

 

등장 배경: 'as const' 로는 부족했던 것.

satisfies 이전에도 상수 객체를 다룰 때 자주 쓰던 방식이 있었습니다. 바로 'as const'입니다.

const palette = {
  red: '#ff0000',
  green: '#00ff00',
  bleu: '#0000ff', // 오타!
} as const;


'as const'는 값의 리터럴 타입을 유지하는 데는 효과적이지만, 키 오타나 누락을 잡아주지 않습니다. 위 코드는 오타가 있어도 그냥 통과합니다. 반면 타입 어노테이션(: Record<Color, string>)을 붙인다면 타입 검증은 되지만, 값의 타입이 string으로 넓혀져 리터럴 타입 정보가 사라지게 됩니다.

'satisfies'는 이 둘의 단점을 동시에 해결합니다. 검증은 하되, 타입을 덮어씌우지 않는다는 것이죠.

 

기본 예제는.

'satisfies'를 활용할 수 있는 가장 전형적인 예시는 유니온 키를 가진 맵을 검증하는 패턴입니다.

다음 예제 코드를 살펴보겠습니다.

type Color = 'red' | 'green' | 'blue';

const palette = {
  red: '#ff0000',
  green: '#00ff00',
  bleu: '#0000ff', // 오타!
} satisfies Record<Color, string>;

// "bleu"는 Color에 없는 key라서 컴파일 에러!

 

 

palette의 타입은 'Record<Color, string>'이 아니라, 객체 리터럴에서 추론된 구체 타입 그대로입니다. 즉, 'palette.red'의 타입은 string이 아니라 '#ff0000' 리터럴이 되는 것이죠. 검증도 되면서, 디테일한 타입 추론도 살아 있습니다.


그래서 언제 쓰면 좋은가.

- 설정을 위한 상수 객체 검증과 같은 경우가 가장 적합한 사용처 중 하나입니다. 라우팅 테이블, 권한 매핑, 컬러 팔레트, API 엔드포인트 맵처럼 '키-값 상수 맵'에서 오타와 누락을 잡으면서, 동시에 자동완성과 정교한 추론을 유지하고 싶을 때 잘 맞습니다.

- 유니온 키를 모두 커버해야 하는 맵에도 유용합니다. 'type Page = "home" | "about" | "settings"' 같은 유니온에 대해 모든 키가 빠짐없이 정의되어야 한다면, 'Record<Page, ...>'와 함께 'satisfies'를 쓰면 키 누락과 여분 키 모두 잡아낼 수 있습니다.

- 리터럴 타입을 유지한 채 검증이 필요할 때도 좋습니다. 타입 주석은 값의 리터럴 타입을 상위 타입으로 넓히지만, 'satisfies'는 리터럴 그대로 유지해서 discriminated union이나 정교한 타입 좁히기에 유리합니다.


내 코드에 적용해보기(switch + default case).

실제로 'satisfies'를 적용한 케이스를 하나 소개해보겠습니다.

다음 코드를 살펴보면, 'shape.type'에 따라 Konva 컴포넌트를 렌더링하는 'ShapeNodeRenderer'인데, 구현이 'switch-case'로 작성되어 있습니다.

export const ShapeNodeRenderer = ({ shape }: { shape: Shape }) => {
  switch (shape.type) {
    case SHAPE_TYPE.FREE_DRAW:
    case SHAPE_TYPE.LINE:
    case SHAPE_TYPE.POLYGON:
      return <Line ... />;
    case SHAPE_TYPE.RECT:
      return <Rect ... />;
    case SHAPE_TYPE.ELLIPSE:
      return <Ellipse ... />;
    default:
      return null; // ← 새 타입 추가 시 조용히 무시됨
  }
};


여기서, 다시 면접에서의 질문을 떠올려 보겠습니다.

새로운 타입이 추가되었을 때, 이 switch-case에서 case 작성의 누락을 방지하고 컴파일 타임에 작성을 강제할 방법이 있나요?

 

현재 코드의 문제는 'default: return null' 입니다. SHAPE_TYPE에 만약 새 타입(ARROW)가 추가된다고 가정해보겠습니다. 타입에는 새로운 ARROW 타입이 만들어지고, 이런 식의 type Shape이 완성될 것입니다. export type Shape = RectShape | EllipseShape | LinearShape | ArrowShape;

 

다만, default가 'null'을 반환하기 때문에, 컴파일 에러도 없고, 별 문제 없이 돌아갈 겁니다. 최악의 경우에는 화면에 나타나지 않는 것을 보고 나서야, 렌더링 코드를 빼먹었다는 사실을 알고 뒤늦게 코드를 고치게 됩니다.

그래서, 제게 필요한 것은 다음과 같았습니다.

새 ShapeType이 추가되면, 맵에서 누락되었다는 사실을 런타임 전, 컴파일 타임에 강제로 알려야 한다.

 

 

1차 접근: 렌더러 맵 + 'satisfies'

switch-case를 렌더러 맵으로 바꾸고, satisfies로 키 누락을 강제하려 했습니다.

const shapeRenderers = {
  [SHAPE_TYPE.FREE_DRAW]: (shape: LinearShape) => <Line ... />,
  [SHAPE_TYPE.LINE]: (shape: LinearShape) => <Line ... />,
  [SHAPE_TYPE.POLYGON]: (shape: LinearShape) => <Line ... />,
  [SHAPE_TYPE.RECT]: (shape: RectShape) => <Rect ... />,
  [SHAPE_TYPE.ELLIPSE]: (shape: EllipseShape) => <Ellipse ... />,
} satisfies Record<ShapeType, (shape: Shape) => JSX.Element>;
//!! 타입 에러: '(shape: LinearShape) => ...' 은 '(shape: Shape) => ...' 에 할당 불가


그럴싸해 보였던 위 시도는 바로 타입 에러에 직면하게 됩니다. TypeScript가 두 함수를 서로 다른 타입으로 판단했기 때문입니다.
- (shape: LinearShape) 는 'LinearShape만' 받을 수 있는 함수
- Record<ShapeType, (shape: Shape) => ...>는 '어떤 Shape든 받을 수 있어야' 한다고 요구

물론 위 문제를 해결하기 위한 다양한 방법이 있습니다. satifies를 유지하면서요.

satisfies Record<ShapeType, (shape: any) => ...>처럼 'any'를 사용해 우회할 수도 있고 (사실 이건 타입스크립트를 쓰는 의미가 없죠), 조건부 타입으로 "키에 맞는 Shape 타입을 추론"하도록 만드는 방법도 있습니다.

예를 들면 아래와 같은 코드로 만들 수 있습니다.

type ShapeForType<K extends ShapeType> = Shape extends infer S
  ? S extends { type: infer T }
    ? K extends T   // ← Extract와 방향이 반대: "K가 T에 포함되는가"
      ? S
      : never
    : never
  : never;


// 최종 satisfies 타입은 아래와 같이 된다.
satisfies { [K in ShapeType]: (shape: ShapeForType<K>) => JSX.Element }

하지만 너무 과하게 복잡하고, 유지보수성이 떨어진다고 판단했습니다. 더 쉬운 방법이 있을 것이라 생각했고, 결국 명시적 매핑 타입으로 방향을 바꿨습니다.

 

개선 결과: `ShapeTypeMap`으로 단순하고 명시적으로.

key 별 Shape 타입을 명시적으로 정의하는 ShapeTypeMap을 만들어 두고, 그 타입을 기준으로 `satisfies` 제약을 걸었습니다.

type ShapeTypeMap = {
  [SHAPE_TYPE.FREE_DRAW]: LinearShape;
  [SHAPE_TYPE.LINE]: LinearShape;
  [SHAPE_TYPE.POLYGON]: LinearShape;
  [SHAPE_TYPE.RECT]: RectShape;
  [SHAPE_TYPE.ELLIPSE]: EllipseShape;
};


이제 렌더러 맵은 이렇게 타입을 제한할 수 있습니다.

const shapeRenderers = {
  [SHAPE_TYPE.FREE_DRAW]: (shape: LinearShape) => <Line ... />,
  [SHAPE_TYPE.LINE]: (shape: LinearShape) => <Line ... />,
  [SHAPE_TYPE.POLYGON]: (shape: LinearShape) => <Line ... />,
  [SHAPE_TYPE.RECT]: (shape: RectShape) => <Rect ... />,
  [SHAPE_TYPE.ELLIPSE]: (shape: EllipseShape) => <Ellipse ... />,
} satisfies { [K in ShapeType]: (shape: ShapeTypeMap[K]) => JSX.Element };


위 방식의 장점은 다음과 같습니다.

- ShapeType에 새 값이 추가되면 렌더러 맵에서 키 누락이 컴파일 에러로 드러납니다.
- 각 렌더러는 자신이 다루는 'shape'의 타입을 정확히 알고 구현할 수 있습니다.
- 복잡한 타입 트릭 없이도 팀원이 읽기 쉬운 코드 형태로 유지됩니다.

다만 한 가지 트레이드오프를 짚어본다면, 위와 같이 작성할 경우 'ShapeTypeMap'과 'ShapeType' 유니온을 따로 관리해야 하기 때문에 새 타입을 추가할 때 두 곳을 함께 수정해야 한다는 점입니다. 팀 규모나 코드베이스 성격에 따라 이렇게 한 번의 수정이 필요할 때 여러 코드가 바뀌어야 한다는 점이 부담이 될 수 있습니다.


그 외 satisfies 사용 시 주의점.

변수 타입이 TargetType으로 바뀌지 않는다.

 

- 간단히 예시를 들어보면, const cfg = {...} satisfies Config 라고 써도 cfg의 타입이 Config가 되는 것은 아닙니다. cfg는 여전히 객체 리터럴에서 추론된 구체 타입을 가지며, 단지 그 타입이 Config에 할당 가능해야 한다는 제약만 검사받습니다.

타입 어노테이션을 완전히 대체하지는 않는다.

- 변수의 타입을 명시적으로 넓혀야 하는 경우(예: API 표면 타입으로 강제)에는 여전히 ': Config' 같은 타입 어노테이션이 필요합니다. 'satisfies'는 검증 및 구체 추론에 맞춰진 도구이고, 모든 타입 선언을 대체하는 키워드는 아닙니다.

런타임 검증이 아니다.

- 'satisfies'는 컴파일 타임에만 동작하며, 빌드 후의 JS 코드에는 영향을 주지 않습니다. 서버에서 온 JSON 같은 런타임 데이터 검증은 여전히 zod 같은 다른 도구가 필요합니다.


마무리.

결론적으로 해당 기술 면접은 통과하지 못했습니다. 면접이 끝나고 시간이 좀 지나 satisfies를 들여다보고 실제 코드에 적용해보면서, '간만에 배울 점이 있었던 좋은 면접이었다' 는 생각이 들었습니다.

축구에서 토너먼트 탈락 후 사용하는 단골 레퍼토리가 있습니다. '아쉽지만 많이 배웠다, 좋은 경험이었다.' 딱히 할 말이 없으니 의례 그냥 하는 말처럼 들립니다만, 이번의 제 경험은 satisfies 키워드 하나 배워갈 수 있었기에 정말로 '좋은 경험'이 되었다고 생각합니다.