🔥 as / satisfies / as const: 세 가지 단언

967자
9분

as / satisfies / as const 세 단언을 한 화면에 비교 — as 는 타입을 덮어쓰고 satisfies 는 제약 검사만 더하고 as const 는 리터럴 + readonly 로 동결한다

빨간 줄을 끄던 as 가 만든 런타임 오류

처음 JSON 응답을 TypeScript 파일에 붙여 넣고 타입을 맞출 때 as 를 자주 썼다. bunx tsc --noEmit 이 빨간 줄을 내면 as User 를 붙였고, 다시 돌리면 컴파일러는 빨간 줄을 더 내지 않았다. 그때는 타입 오류를 해결했다고 생각했다. 나중에 user.name.toUpperCase() 에서 런타임 오류를 보고 나서야 as 가 검사 결과를 바꾼 게 아니라 내 지시를 컴파일러가 받아들인 것뿐이라는 점을 알았다.

as 는 검사 근거 없이 타입을 지정한다

as 는 값을 검사하지 않는다. 값을 지정한 타입으로 취급한다. 이 동작은 좁히기와 다르다. 좁히기는 typeof, if, 사용자 정의 타입 가드 같은 조건을 근거로 컴파일러가 타입을 줄이는 일이다. as 는 그 검사 근거 없이 타입을 지정한다.

좁히기 (typeof / if / 타입 가드) 와 as 단언의 차이 — 위 레인은 조건을 근거로 타입을 줄이고, 아래 레인은 string 을 number 로 덮어쓰면서 컴파일 검사도 런타임 검사도 통과시킨다

ts
// file: as-direction.ts
export {};
 
type ApiUser = {
  id: string;
  name: string;
};
 
const raw: unknown = JSON.parse('{"id":"u1","name":"Ada"}');
 
const user = raw as ApiUser;
// ^? ApiUser
 
console.log(user.name.toUpperCase());
 
const countText = "42";
 
const unsafeCount = countText as unknown as number;
// ^? number
 
console.log(unsafeCount + 1);
ts
// file: as-direction.ts
export {};
 
type ApiUser = {
  id: string;
  name: string;
};
 
const raw: unknown = JSON.parse('{"id":"u1","name":"Ada"}');
 
const user = raw as ApiUser;
// ^? ApiUser
 
console.log(user.name.toUpperCase());
 
const countText = "42";
 
const unsafeCount = countText as unknown as number;
// ^? number
 
console.log(unsafeCount + 1);

첫 단언은 unknown 값을 ApiUser 로 다룬다. any 와 unknown 을 따로 다룬 편에서 본 것처럼 unknown 은 바로 속성을 읽을 수 없다. 그래서 실제 코드에서는 보통 검사 함수를 먼저 두고, 그 검사 뒤에 좁은 타입으로 단언한다. 아래쪽 unsafeCount 는 다르다. 문자열을 숫자로 바꾸지 않고 숫자라고 지시했다. as unknown as number 는 컴파일러 검사를 강하게 우회하는 패턴이다. 값 자체는 여전히 문자열이다.

satisfies 는 제약을 더하고 추론을 유지한다

satisfies 는 타입 변경 대신 제약 검사를 한다. 값을 어떤 타입으로 바꾸지 않고, 그 값이 제약을 만족하는지만 검사한다. 그래서 객체 리터럴의 구체적인 키와 값 추론을 유지한다. 설정 객체와 매핑 객체에서 이 차이를 바로 확인할 수 있다.

as 와 satisfies 가 같은 색상 매핑 객체에서 키 추론을 다르게 처리하는 비교 — as 는 키를 string 으로 잊고, satisfies 는 primary | danger 두 키를 그대로 보존한다

ts
// file: satisfies-vs-as.ts
export {};
 
const paletteAs = {
  primary: "#2563eb",
  danger: "#dc2626",
} as Record<string, string>;
 
const paletteSatisfies = {
  primary: "#2563eb",
  danger: "#dc2626",
} satisfies Record<string, string>;
 
const fromAs = paletteAs.primary;
// ^? string
 
const fromSatisfies = paletteSatisfies.primary;
// ^? string
 
type AsKeys = keyof typeof paletteAs;
// ^? string
 
type SatisfiesKeys = keyof typeof paletteSatisfies;
// ^? "primary" | "danger"
ts
// file: satisfies-vs-as.ts
export {};
 
const paletteAs = {
  primary: "#2563eb",
  danger: "#dc2626",
} as Record<string, string>;
 
const paletteSatisfies = {
  primary: "#2563eb",
  danger: "#dc2626",
} satisfies Record<string, string>;
 
const fromAs = paletteAs.primary;
// ^? string
 
const fromSatisfies = paletteSatisfies.primary;
// ^? string
 
type AsKeys = keyof typeof paletteAs;
// ^? string
 
type SatisfiesKeys = keyof typeof paletteSatisfies;
// ^? "primary" | "danger"

컴파일러는 paletteAsRecord<string, string> 으로 다룬다. 그래서 컴파일러가 키 유니온을 string 까지 넓혀 잡는다. paletteSatisfiesRecord<string, string> 제약을 통과해야 하지만, 원래 객체의 키는 그대로 남는다. primarydanger 를 키 유니온으로 다시 꺼낼 수 있다. as 는 타입을 덮고, satisfies 는 제약만 더한다.

as const 는 리터럴 보존에 쓰는 단언이다

as const 는 리터럴 보존에 쓰는 단언이다. 이 표현은 값을 가장 좁은 리터럴 타입으로 받고 객체와 배열을 readonly 로 고정한다. 기본 동작은 as const 가 객체와 배열을 readonly 로 동결하는 편에서 이미 다뤘다. 아래 코드는 같은 객체를 네 방식으로 받을 때 컴파일러가 남기는 정보를 비교한다.

as const 가 plain object literal 의 mode string · retry number 와 다르게 mode "dark" · retry 3 리터럴 + readonly 로 좁히는 비교

ts
// file: as-const-vs-others.ts
export {};
 
type Config = {
  mode: "light" | "dark";
  retry: number;
};
 
const plain = {
  mode: "dark",
  retry: 3,
};
 
const frozen = {
  mode: "dark",
  retry: 3,
} as const;
 
const asserted = {
  mode: "dark",
  retry: 3,
} as Config;
 
const checked = {
  mode: "dark",
  retry: 3,
} satisfies Config;
 
const plainMode = plain.mode;
// ^? string
 
const frozenMode = frozen.mode;
// ^? "dark"
 
const assertedMode = asserted.mode;
// ^? "light" | "dark"
 
const checkedMode = checked.mode;
// ^? "dark"
 
frozen.retry;
// ^? 3
ts
// file: as-const-vs-others.ts
export {};
 
type Config = {
  mode: "light" | "dark";
  retry: number;
};
 
const plain = {
  mode: "dark",
  retry: 3,
};
 
const frozen = {
  mode: "dark",
  retry: 3,
} as const;
 
const asserted = {
  mode: "dark",
  retry: 3,
} as Config;
 
const checked = {
  mode: "dark",
  retry: 3,
} satisfies Config;
 
const plainMode = plain.mode;
// ^? string
 
const frozenMode = frozen.mode;
// ^? "dark"
 
const assertedMode = asserted.mode;
// ^? "light" | "dark"
 
const checkedMode = checked.mode;
// ^? "dark"
 
frozen.retry;
// ^? 3

그대로 둔 plainmodestring 으로 넓힌다. frozen"dark"3 을 그대로 남기고 속성을 읽기 전용으로 둔다. asserted 는 값을 Config 로 다루기 때문에 mode"light" | "dark" 가 된다. checkedConfig 제약을 검사하면서도 실제 값 "dark" 를 보존한다. 설정 객체를 만들 때 오타 검사는 필요하고 구체 키와 값은 남겨야 한다면 satisfies 가 맞다.

! 는 null 가능성만 제거하는 짧은 단언이다

null 아님 단언인 ! 도 단언이다. value! 는 타입에서 nullundefined 가능성을 제거한다. 값 검사는 하지 않는다. 그래서 직전에 null 가능성을 제거한 조건이 있을 때만 좁게 쓰는 편이 낫다.

non-null 단언 ! 와 if (maybeUser) 좁히기가 같은 결과 타입을 만드는 두 경로 비교 — 왼쪽은 조건 없이 단언으로 타입에서 null 을 제거하고, 오른쪽은 if 조건이 코드에 그대로 남아 null 분기를 처리한다

ts
// file: non-null-bang.ts
export {};
 
type User = {
  id: string;
  name: string;
};
 
function getUser(id: string): User | null {
  return id === "u1" ? { id, name: "Ada" } : null;
}
 
const maybeUser = getUser("u1");
 
const nameWithBang = maybeUser!.name;
// ^? string
 
if (maybeUser) {
  const nameAfterCheck = maybeUser.name;
  // ^? string
  console.log(nameAfterCheck.toUpperCase());
}
 
console.log(nameWithBang.toUpperCase());
ts
// file: non-null-bang.ts
export {};
 
type User = {
  id: string;
  name: string;
};
 
function getUser(id: string): User | null {
  return id === "u1" ? { id, name: "Ada" } : null;
}
 
const maybeUser = getUser("u1");
 
const nameWithBang = maybeUser!.name;
// ^? string
 
if (maybeUser) {
  const nameAfterCheck = maybeUser.name;
  // ^? string
  console.log(nameAfterCheck.toUpperCase());
}
 
console.log(nameWithBang.toUpperCase());

maybeUser!.namemaybeUser 를 null 이 아닌 값으로 취급하고 name 을 읽는다. 바로 아래 if (maybeUser) 는 같은 조건을 코드로 확인한다. 두 방식 모두 호버에서는 string 을 얻지만 의미는 다르다. ! 는 확인 조건을 생략하고, if 는 확인 조건을 코드에 남긴다.

어느 단언을 언제 쓰나

tsc 빨간 줄 앞에서 satisfies / 좁히기 / value! / as 네 갈래 결정 트리 — 제약 검사면 satisfies, 좁힐 수 있으면 narrowing, 직전에 null 을 제거했으면 value!, 그 외에는 위험 단언 as Type

팀 코드에서 as 를 오류 표시만 없애는 용도로 붙이면 대개 위험 단언이다. 그 코드에서는 먼저 두 가지를 확인한다. 값이 제약을 만족하는지만 확인하면 되는지, 아니면 if 나 타입 가드로 먼저 좁힐 수 있는지다. 전자는 satisfies 로 해결하는 경우가 많고, 후자는 컴파일러가 좁은 타입으로 추론한다. as const 는 리터럴을 고정할 때 쓰고, ! 는 null 아님을 바로 앞 코드가 이미 설명한 경우에만 짧게 쓴다.

YouTube 영상

채널 보기
AI는 왜 수백 차원의 벡터를 사용할까? 고차원 공간과 행렬 | 선형대수학
행렬의 가장 중요한 연산 - 행렬 곱셈 | 선형대수학
트라이(Trie)를 이용한 자동 완성 알고리즘 | Trie 자료구조 이야기
AI를 위한 선형대수학 - 소개 | 선형대수학
트라이(Trie)에서 단어를 삭제하는 방법 | Trie 자료구조 이야기
우리가 매일 쓰는 맞춤법 검사기와 라우터 속에 숨겨진 알고리즘은? | Trie 자료구조 이야기
숫자 하나가 AI 모델의 운명을 바꾼다? | 선형대수학
행렬의 기본 연산 - 행렬 덧셈, 스칼라 곱, 전치 | 선형대수학