🔥 as / satisfies / as const: 세 가지 단언
강의 목차

빨간 줄을 끄던 as 가 만든 런타임 오류
처음 JSON 응답을 TypeScript 파일에 붙여 넣고 타입을 맞출 때 as 를 자주 썼다. bunx tsc --noEmit 이 빨간 줄을 내면 as User 를 붙였고, 다시 돌리면 컴파일러는 빨간 줄을 더 내지 않았다. 그때는 타입 오류를 해결했다고 생각했다. 나중에 user.name.toUpperCase() 에서 런타임 오류를 보고 나서야 as 가 검사 결과를 바꾼 게 아니라 내 지시를 컴파일러가 받아들인 것뿐이라는 점을 알았다.
as 는 검사 근거 없이 타입을 지정한다
as 는 값을 검사하지 않는다. 값을 지정한 타입으로 취급한다. 이 동작은 좁히기와 다르다. 좁히기는 typeof, if, 사용자 정의 타입 가드 같은 조건을 근거로 컴파일러가 타입을 줄이는 일이다. as 는 그 검사 근거 없이 타입을 지정한다.

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

// 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"// 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"컴파일러는 paletteAs 를 Record<string, string> 으로 다룬다. 그래서 컴파일러가 키 유니온을 string 까지 넓혀 잡는다. paletteSatisfies 는 Record<string, string> 제약을 통과해야 하지만, 원래 객체의 키는 그대로 남는다. primary 와 danger 를 키 유니온으로 다시 꺼낼 수 있다. as 는 타입을 덮고, satisfies 는 제약만 더한다.
as const 는 리터럴 보존에 쓰는 단언이다
as const 는 리터럴 보존에 쓰는 단언이다. 이 표현은 값을 가장 좁은 리터럴 타입으로 받고 객체와 배열을 readonly 로 고정한다. 기본 동작은 as const 가 객체와 배열을 readonly 로 동결하는 편에서 이미 다뤘다. 아래 코드는 같은 객체를 네 방식으로 받을 때 컴파일러가 남기는 정보를 비교한다.

// 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// 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그대로 둔 plain 은 mode 를 string 으로 넓힌다. frozen 은 "dark" 와 3 을 그대로 남기고 속성을 읽기 전용으로 둔다. asserted 는 값을 Config 로 다루기 때문에 mode 가 "light" | "dark" 가 된다. checked 는 Config 제약을 검사하면서도 실제 값 "dark" 를 보존한다. 설정 객체를 만들 때 오타 검사는 필요하고 구체 키와 값은 남겨야 한다면 satisfies 가 맞다.
! 는 null 가능성만 제거하는 짧은 단언이다
null 아님 단언인 ! 도 단언이다. value! 는 타입에서 null 과 undefined 가능성을 제거한다. 값 검사는 하지 않는다. 그래서 직전에 null 가능성을 제거한 조건이 있을 때만 좁게 쓰는 편이 낫다.

// 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());// 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!.name 은 maybeUser 를 null 이 아닌 값으로 취급하고 name 을 읽는다. 바로 아래 if (maybeUser) 는 같은 조건을 코드로 확인한다. 두 방식 모두 호버에서는 string 을 얻지만 의미는 다르다. ! 는 확인 조건을 생략하고, if 는 확인 조건을 코드에 남긴다.
어느 단언을 언제 쓰나

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












