Javascript

TypeScript satisfies 연산자 완벽 정리: 타입 체크의 새로운 강자

드리프트2 2025. 3. 3. 17:03

TypeScript satisfies 연산자 완벽 정리: 타입 체크의 새로운 강자

 

안녕하세요, 여러분!

오늘은 TypeScript의 satisfies 연산자가 뭔지, 어떻게 동작하는지 알아볼까요?

이 연산자는 값의 타입을 체크하면서도 (대부분) 타입에 영향을 주지 않는 멋진 기능인데요.

어디에 유용한지도 함께 살펴볼게요.

이 글에서 사용할 표기법

소스 코드에서 계산된 타입이나 추론된 타입을 보여주기 위해 asserttt라는 npm 패키지를 사용합니다.

예를 들어:

// 값의 타입
assertType<string>('abc');
assertType<number>(123);

// 타입의 동등성
type Pair<T> = [T, T];
type _ = Assert<Equal<
  Pair<string>, [string,string]
>>;

satisfies 연산자는 뭘까요?

 

satisfies 연산자는 컴파일 타임에서 주어진 값이 특정 타입에 할당 가능한지 강제합니다:

value satisfies Type

 

결과는 여전히 value입니다.

이 연산자는 런타임에는 아무런 영향을 주지 않습니다.

보통 value의 타입은 변하지 않는데요.

다만, 몇 가지 예외가 있으니 나중에 살펴볼게요.

문법: satisfies 앞에 줄 바꿈은 안 됩니다

아래는 허용되지 않습니다:

const sayHello = (name) => `Hello ${name}!`
  // @ts-expect-error: Cannot find name 'satisfies'.
  satisfies (str: string) => string;

 

이럴 때는 괄호를 사용하면 됩니다:

const sayHello = (
  (name) => `Hello ${name}!`
) satisfies (str: string) => string;

 

첫 번째 예제

 

객체 리터럴의 타입이 다양한 어노테이션에 따라 어떻게 영향을 받는지 알아볼까요?

먼저 아무런 어노테이션 없이 시작해봅시다:

const point1 = { x: 2, y: 5 };
assertType<
  { x: number, y: number }
>(point1);

 

TypeScript는 .x.y의 타입을 number로 일반화합니다.

이제 as const 어노테이션을 사용하면 어떻게 되는지 볼까요:

const point2 = { x: 2, y: 5 } as const;
assertType<
  { readonly x: 2, readonly y: 5 }
>(point2);

 

이제 두 속성은 읽기 전용이 되고, 타입도 좁아졌습니다.

만약 새로운 타입 Point를 사용해 포인트의 형태가 올바른지 확인하려면 어떻게 될까요?

type Point = { x: number, y: number };
const point3: Point = { x: 2, y: 5 } as const;
assertType<
  Point
>(point3);

 

point3은 다시 넓은 타입을 가집니다.

TypeScript는 point1이 가졌던 타입의 별칭인 Point를 추론합니다.

타입 레벨 어노테이션 없이 변수를 선언했기 때문인데요.

satisfies를 사용하면 as const가 주는 좁은 타입을 유지하면서 포인트의 형태가 올바른지 확인할 수 있습니다:

const point4 = { x: 2, y: 5 } as const satisfies Point;
assertType<
  { readonly x: 2, readonly y: 5 }
>(point4);

 

satisfiesas와 어떻게 다를까요?

한편으로, as는 일반적으로 왼쪽 값의 타입을 변경합니다.

반면, satisfies만큼 철저하게 타입을 체크하지는 않습니다:

// 누락된 속성에 대해 경고해야 하지만 하지 않습니다!
const point5 = { x: 2 } as const as Point; // OK
assertType<
  Point
>(point5);

 

반면, satisfies.y 속성이 누락된 경우 경고를 줍니다:

// @ts-expect-error: Type '{ readonly x: 2; }' does not satisfy
// the expected type 'Point'.
const point6 = { x: 2 } as const satisfies Point;

 

포인트의 타입을 왜 좁게 유지하고 싶을까요?

사실, 넓은 타입으로도 충분한 경우가 많습니다.

하지만 좁은 타입이 필요한 사용 사례도 있으니, 다음에 살펴볼게요.

예제: 선택적 객체 속성

아래 코드는 partialPoint1이 넓은 타입 PartialPoint를 가져서 작업하기 불편한 상황을 보여줍니다:

type PartialPoint = { x?: number, y?: number };

const partialPoint1: PartialPoint = { y: 7 };

// 에러가 나야 합니다
const x1 = partialPoint1.x;
assertType<number | undefined>(x1);

// 타입은 `number`여야 합니다
const y1 = partialPoint1.y;
assertType<number | undefined>(y1);

 

satisfies를 사용하면 어떻게 되는지 볼까요:

const partialPoint2 = { y: 7 } satisfies PartialPoint;

// @ts-expect-error: Property 'x' does not exist on type
// '{ y: number; }'.
const x2 = partialPoint2.x;

const y2 = partialPoint2.y;
assertType<number>(y2);

 

객체 속성 값 타입 체크

 

다음 객체는 텍스트 스타일에 대한 열거형을 구현합니다:

const TextStyle = {
  Bold: {
    html: 'b',
    latex: 'textbf',
  },
  Italics: {
    html: 'i',
    // 누락: latex
  },
};

type TextStyleKeys = keyof typeof TextStyle;
type _ = Assert<Equal<
  TextStyleKeys, "Bold" | "Italics"
>>;

 

한편으로, 속성 키를 추출해 TextStyleKeys 타입을 얻을 수 있는 점은 멋집니다.

다른 한편으로, TextStyle.Italics.latex 속성을 잊어버렸는데도 TypeScript가 경고를 주지 않았습니다.

개선: 속성 값 타입

속성 값을 체크하기 위해 다음 타입을 사용해봅시다:

type TTextStyle = {
  html: string,
  latex: string,
};

 

첫 번째 시도는 다음과 같습니다:

const TextStyle: Record<string, TTextStyle>  = {
  Bold: {
    html: 'b',
    latex: 'textbf',
  },
  // @ts-expect-error: Property 'latex' is missing in type
  // '{ html: string; }' but required in type 'TTextStyle'.
  Italics: {
    html: 'i',
  },
};

type TextStyleKeys = keyof typeof TextStyle;
type _ = Assert<Equal<
  TextStyleKeys, string
>>;

 

장점: 누락된 속성에 대해 경고를 받습니다.


단점: 더 이상 속성 키를 추출할 수 없습니다 – TextStyleKeys가 이제 string이 됐습니다.

개선: satisfies로 속성 값 체크

다시 한 번, satisfies가 도움을 줄 수 있습니다:

const TextStyle  = {
  Bold: {
    html: 'b',
    latex: 'textbf',
  },
  // @ts-expect-error: Property 'latex' is missing in type
  // '{ html: string; }' but required in type 'TTextStyle'.
  Italics: {
    html: 'i',
  },
} satisfies Record<string, TTextStyle>;

type TextStyleKeys = keyof typeof TextStyle;
type _ = Assert<Equal<
  TextStyleKeys, "Bold" | "Italics"
>>;

 

이제 누락된 속성에 대한 경고를 받으면서도 유용한 TextStyleKeys 타입을 얻을 수 있습니다.

객체 속성 키 타입 체크

앞의 예제에서는 속성 값의 형태를 체크했습니다.

때로는 속성 키도 제한된 집합으로 관리하고 싶을 때가 있습니다 – 오타를 방지하기 위해서 말이죠.

다음 예제는 satisfies에 대한 풀 리퀘스트에서 영감을 얻은 것이며, 속성 키를 체크하기 위해 ColorName 타입을, 속성 값을 체크하기 위해 Color 타입을 사용합니다.

'blue'가 누락돼서 에러가 발생합니다:

type ColorName = 'red' | 'green' | 'blue';
type Color =
  | string // hex
  | [number, number, number] // RGB
;
const fullColorTable = {
  red: [255, 0, 0],
  green: '#00FF00',
// @ts-expect-error:
// Type '{ red: [number, number, number]; green: string; }'
// does not satisfy the expected type 'Record<ColorName, Color>'.
} satisfies Record<ColorName, Color>;

 

모든 키를 사용하지 않으면서도 오타를 체크하고 싶다면 어떻게 해야 할까요?

객체의 레코드를 부분적으로 만들면 됩니다 (A 라인):

const partialColorTable = {
  red: [255, 0, 0],
  // @ts-expect-error: Object literal may only specify known
  // properties, but 'greenn' does not exist in type
  // 'Partial<Record<ColorName, Color>>'.
  // Did you mean to write 'green'?
  greenn: '#00FF00',
} satisfies Partial<Record<ColorName, Color>>; // (A)

 

속성 키를 추출할 수도 있습니다:

const partialColorTable2 = {
  red: [255, 0, 0],
  green: '#00FF00',
} satisfies Partial<Record<ColorName, Color>>;

type PropKeys = keyof typeof partialColorTable2;
type _ = Assert<Equal<
  PropKeys, "red" | "green"
>>;

 

리터럴 값 제약

 

다음 함수 호출을 생각해봅시다:

JSON.stringify({ /*···*/ });

 

JSON.stringify()의 파라미터는 any 타입을 가집니다.

우리가 전달하는 객체가 올바른 형태인지(속성 키 오타 등이 없는지) 어떻게 보장할 수 있을까요?

한 가지 방법은 객체를 변수로 만드는 것입니다:

const obj: SomeType = { /*···*/ };
JSON.stringify(obj);

 

또 다른 방법은 satisfies를 사용하는 것입니다:

JSON.stringify({ /*···*/ } satisfies SomeType);

 

다음 하위 섹션에서는 이 기법을 여러 사용 사례에 적용해봅시다.

예제: fetch()로 JSON 포스팅

다음 코드는 Matt Pocock의 글에서 영감을 얻은 것입니다: fetch()를 사용해 API에 데이터를 포스팅합니다.

type Product = {
  name: string,
  quantity: number,
};

const response = await fetch('/api/products', {
  method: 'POST',
  body: JSON.stringify(
    {
      name: 'Toothbrush',
      quantity: 3,
    } satisfies Product
  ),
  headers: {
    'Content-Type': 'application/json',
  },
});

예제: export default

인라인 명명된 내보내기는 타입 어노테이션을 가질 수 있습니다:

import type { StringCallback } from './core.js';

export const toUpperCase: StringCallback =
  (str) => str.toUpperCase();

 

기본 내보내기에는 타입 어노테이션을 추가할 방법이 없지만, satisfies를 사용할 수 있습니다:

import type { StringCallback } from './core.js';

export default (
  (str) => str.toUpperCase()
) satisfies StringCallback;

satisfies가 항상 필요한 건 아닙니다

 

이 섹션에서는 satisfies가 필요할 것 같지만, TypeScript가 이미 코드를 올바르게 좁히는 경우를 살펴볼게요.

예제: 문자열과 숫자의 유니언

다음 코드에서는 str1str2satisfies를 사용할 필요가 없습니다 – 이미 string으로 좁혀져 있습니다:

type StrOrNum = string | number;

const str1 = 'abc' as StrOrNum;
// @ts-expect-error: Property 'toUpperCase' does not exist on
// type 'StrOrNum'.
str1.toUpperCase();

const str2: StrOrNum = 'abc';
str2.toUpperCase(); // OK

let str3: StrOrNum = 'abc';
str3.toUpperCase(); // OK

 

예제: 구별된 유니언

 

다음 예제에서 linkToIntro도 구별된 유니언 LinkHref의 한 요소로 좁혀집니다:

type LinkHref =
  | {
    kind: 'LinkHrefUrl',
    url: string,
  }
  | {
    kind: 'LinkHrefId',
    id: string,
  }
;
const linkToIntro: LinkHref = {
  kind: 'LinkHrefId',
  id: '#intro',
};
// 타입이 좁혀졌습니다:
assertType<
  {
    kind: 'LinkHrefId',
    id: string,
  }
>(linkToIntro);

satisfies는 추론된 타입을 변경할 수 있습니다

 

satisfies는 보통 값의 추론된 타입을 변경하지 않지만, 예외가 있습니다.

타입에서 리터럴 타입으로

type Robin = {
  name: 'Robin',
};

const robin1 = { name: 'Robin' };
assertType<string>(robin1.name);

const robin2 = { name: 'Robin' } satisfies Robin;
assertType<'Robin'>(robin2.name);

배열에서 튜플로

// `satisfies` 없음
const tuple1 = ['a', 1];
assertType<
  (string | number)[]
>(tuple1);

// 비어 있지 않은 튜플
const tuple2 = ['a', 1] satisfies [unknown, ...unknown[]];
assertType<
  [string, number]
>(tuple2);

// 모든 튜플
const tuple3 = [] satisfies [unknown?, ...unknown[]];
assertType<
  []
>(tuple3);

// 모든 튜플
const tuple4 = ['a', 1] satisfies [] | unknown[];
assertType<
  [string, number]
>(tuple4);

satisfies는 명시적 타입을 변경하지 않습니다

const tuple1: Array<string | number> = ['a', 1];

// @ts-expect-error: Type '(string | number)[]' does not satisfy
// the expected type '[unknown, ...unknown[]]'.
const tuple2 = tuple1 satisfies [unknown, ...unknown[]];

타입 레벨 만족도 체크

TypeScript에는 satisfies와 유사한 타입 레벨 연산이 있지만, 우리 스스로 구현할 수도 있습니다:

type Satisfies<Type extends Constraint, Constraint> = Type;

type T1 = Satisfies<123, number>; // 123
type _ = Assert<Equal<
  T1, 123
>>;

// @ts-expect-error: Type 'number' does not satisfy
// the constraint 'string'.
type T2 = Satisfies<123, string>;