목록으로

프로그래밍 · TypeScript

TypeScript 완전정복 11화: 타입 단언과 안전한 타입 처리

BeanCon
TypeScript 타입 단언과 안전한 타입 처리 방법을 설명하는 대표 이미지

TypeScript 완전정복 11화입니다. 개발자가 TypeScript보다 값의 타입을 더 잘 알고 있을 때 사용하는 타입 단언과 안전한 타입 처리 기준을 정리했습니다. as 문법, 꺾쇠괄호 타입 단언, 타입 단언과 타입 변환의 차이, 이중 타입 단언의 위험성, unknown 검증, DOM 요소 처리, 비어 있지 않음 단언 연산자, as const, satisfies, 타입 좁히기, in과 instanceof, 사용자 정의 타입 가드, API 응답 검증, 로컬 스토리지와 폼 입력 처리, 실무 판단 기준까지 다룹니다.

목차

타입 단언과 안전한 타입 처리

지난 시간에는 타입을 모르는 값을 다루는 네 가지 특별한 타입을 살펴봤습니다.

let unsafeValue: any;
let safeValue: unknown;

function printLog(): void {
  console.log("로그를 출력합니다.");
}

function throwError(message: string): never {
  throw new Error(message);
}

특히 `unknown`은 어떤 값이든 받을 수 있지만, 실제 타입을 확인하기 전에는 사용할 수 없었습니다.

const value: unknown = "TypeScript";

if (typeof value === "string") {
  console.log(value.toUpperCase());
}

TypeScript는 개발자에게 꽤 신중한 태도를 요구합니다.

“확실하지 않다면 확인부터 해주세요.”

하지만 가끔은 개발자가 TypeScript보다 더 많은 정보를 알고 있는 순간이 있습니다.

브라우저에서 특정 HTML 요소를 가져오는 코드를 살펴보겠습니다.

const inputElement =
  document.querySelector("#user-name");

TypeScript는 `querySelector()`의 결과를 다음처럼 판단할 수 있습니다.

Element | null

TypeScript 입장에서는 다음 두 가지 가능성이 있기 때문입니다.

요소를 찾았다.
→ Element

요소를 찾지 못했다.
→ null

그런데 개발자는 HTML 문서를 직접 작성했기 때문에 `#user-name`이 분명히 `<input>` 요소라는 사실을 알고 있을 수 있습니다.

<input id="user-name" />

이때 TypeScript에게 다음과 같이 말할 수 있습니다.

const inputElement =
  document.querySelector(
    "#user-name"
  ) as HTMLInputElement;

`as HTMLInputElement`는 다음과 같은 의미입니다.

“TypeScript야, 이 값은 일반적인 `Element`가 아니라 `HTMLInputElement`야. 이 부분은 내가 더 정확히 알고 있어.”

이렇게 개발자가 값의 타입을 TypeScript에게 직접 알려주는 것을 타입 단언(Type Assertion)이라고 합니다.

그런데 여기에는 매우 중요한 사실이 있습니다.

const value =
  "100" as unknown as number;

이 코드를 작성했다고 문자열 `"100"`이 실제 숫자 `100`으로 변하는 것은 아닙니다.

타입 단언은 값을 변환하지 않습니다.

TypeScript의 판단만 바꿉니다.

타입 단언은 마법 변신 주문이 아니라 서류 위의 직업란을 수정하는 펜입니다. 🖊️

서류에 “숫자”라고 적었다고 문자열이 계산기를 들고 출근하지는 않습니다.

1. 이번 시간에 배울 내용

이번 시간에는 다음 내용을 알아봅니다.

  • 타입 단언이란 무엇인가?
  • `as` 문법은 어떻게 사용하는가?
  • 꺾쇠괄호 타입 단언은 무엇인가?
  • 타입 단언과 타입 변환은 어떻게 다른가?
  • TypeScript는 어떤 단언을 허용하는가?
  • 이중 타입 단언은 왜 위험한가?
  • `unknown`을 안전하게 단언하는 방법
  • DOM 요소를 안전하게 다루는 방법
  • `!` 비어 있지 않음 단언 연산자
  • `!`가 실제 값을 만들어 주지 않는 이유
  • `as const`는 무엇인가?
  • 일반 타입 단언과 `as const`의 차이
  • `satisfies`로 타입을 안전하게 검사하는 방법
  • 타입 단언보다 타입 좁히기를 우선해야 하는 이유
  • 실무에서 타입 단언을 사용해도 되는 기준

오늘의 핵심 등장인물은 네 가지입니다.

as 타입
“이 값의 타입은 이것입니다.”

!
“이 값은 null이나 undefined가 아닙니다.”

as const
“이 값과 구조를 최대한 그대로 고정해 주세요.”

satisfies
“이 타입 조건을 만족하는지만 검사해 주세요.”

비슷해 보이지만 역할은 전혀 다릅니다.

TypeScript 법정에서 각각 다른 증언을 하는 네 명의 참고인이라고 생각해 보겠습니다. ⚖️

2. 타입 단언이란?

타입 단언은 개발자가 TypeScript에게 값의 타입을 더 구체적으로 알려주는 문법입니다.

const value: unknown =
  "TypeScript";

`value`는 `unknown`이므로 바로 문자열 메서드를 사용할 수 없습니다.

value.toUpperCase();

오류가 발생합니다.

개발자가 이 값이 문자열이라는 사실을 확실히 알고 있다면 다음처럼 단언할 수 있습니다.

const text =
  value as string;

console.log(
  text.toUpperCase()
);

또는 한 줄로 사용할 수 있습니다.

console.log(
  (value as string).toUpperCase()
);

`as string`은 TypeScript에게 다음과 같이 알려줍니다.

현재 TypeScript의 판단
unknown

개발자의 주장
string

단언 이후 TypeScript는 문자열 기능 사용을 허용합니다.

3. 타입 단언의 기본 문법

가장 많이 사용하는 타입 단언 문법은 `as`입니다.

값 as 타입

예를 들어 다음과 같습니다.

const value: unknown =
  "Hello";

const message =
  value as string;

객체에도 사용할 수 있습니다.

type User = {
  id: number;
  name: string;
};

const data: unknown = {
  id: 1,
  name: "김타입",
};

const user =
  data as User;

배열 타입으로도 단언할 수 있습니다.

const data: unknown = [
  "JavaScript",
  "TypeScript",
];

const languages =
  data as string[];

DOM 요소에도 자주 사용합니다.

const input =
  document.querySelector(
    "#user-name"
  ) as HTMLInputElement;

4. 타입 단언은 타입을 검사하지 않는다

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

type User = {
  id: number;
  name: string;
};

const data: unknown = {
  message: "안녕하세요.",
};

const user =
  data as User;

실제 `data`에는 `id`와 `name`이 없습니다.

하지만 타입 단언을 사용하면 TypeScript는 `user`를 `User`로 취급합니다.

console.log(
  user.name.toUpperCase()
);

TypeScript 오류는 표시되지 않을 수 있습니다.

하지만 실행하면 문제가 발생합니다.

TypeError:
Cannot read properties of undefined

타입 단언은 데이터 구조를 검사하지 않습니다.

타입 단언이 하는 일
→ TypeScript의 정적 판단을 변경

타입 단언이 하지 않는 일
→ 실제 데이터 검사
→ 누락된 속성 생성
→ 값 변환
→ 런타임 오류 방지

TypeScript에게 “이 사람은 의사입니다”라고 단언해도 청진기가 자동으로 생기지는 않습니다. 🩺

실제 자격증 확인 없이 흰 가운만 입혀 놓은 상태일 수 있습니다.

5. 타입 단언과 타입 변환은 다르다

다음 코드에서 `"100"`은 문자열입니다.

const value = "100";

타입 단언을 사용해 보겠습니다.

const assertedNumber =
  value as unknown as number;

TypeScript는 `assertedNumber`를 숫자로 취급할 수 있습니다.

하지만 실행 중인 실제 값은 여전히 문자열입니다.

console.log(
  typeof assertedNumber
);

출력 결과:

string

숫자로 실제 변환하려면 변환 함수를 사용해야 합니다.

const convertedNumber =
  Number(value);
console.log(
  typeof convertedNumber
);

출력 결과:

number

둘의 차이를 정리하면 다음과 같습니다.

타입 단언

const result =
  value as unknown as number;
실제 값
"100"

실행 중 타입
string

TypeScript의 판단
number

타입 변환

const result =
  Number(value);
실제 값
100

실행 중 타입
number

TypeScript의 판단
number

타입 단언은 명찰을 바꿉니다.

타입 변환은 실제 사람을 교육하고 직무까지 바꿉니다.

6. 꺾쇠괄호 타입 단언

TypeScript에는 `as` 외에 꺾쇠괄호를 사용하는 타입 단언 문법도 있습니다.

const value: unknown =
  "TypeScript";

const text =
  <string>value;

다음 두 코드는 같은 의미입니다.

const firstText =
  value as string;
const secondText =
  <string>value;

하지만 일반적으로는 `as` 문법이 더 많이 사용됩니다.

value as string

특히 React의 TSX 파일에서는 꺾쇠괄호 문법이 JSX 태그와 혼동될 수 있습니다.

<string>value

TypeScript는 이것이 타입 단언인지 JSX 요소인지 구분하기 어려울 수 있습니다.

따라서 프로젝트 전체에서 `as` 문법을 사용하는 것이 일관성과 호환성 면에서 편리합니다.

const text =
  value as string;

초보 단계에서는 다음처럼 기억하면 충분합니다.

권장 방식
value as Type

가능하지만 덜 사용하는 방식
<Type>value

7. TypeScript는 아무 단언이나 허용할까?

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

const value = "TypeScript";

문자열을 바로 숫자로 단언하려 하면 TypeScript가 오류를 표시할 수 있습니다.

const numberValue =
  value as number;

문자열과 숫자가 충분히 겹치는 타입이 아니기 때문입니다.

오류 메시지는 다음과 비슷합니다.

Conversion of type 'string'
to type 'number'
may be a mistake.

TypeScript도 개발자의 모든 주장을 무조건 믿는 것은 아닙니다.

너무 터무니없는 단언에는 한 번 더 질문합니다.

“문자열을 숫자라고 주장하셨는데, 정말 의도한 것이 맞습니까?”

타입 단언은 일반적으로 현재 타입보다 더 구체적인 타입이나, 서로 어느 정도 관련이 있는 타입 사이에서 사용합니다.

const element =
  document.querySelector(
    "#user-name"
  );

const input =
  element as HTMLInputElement;

`HTMLInputElement`는 `Element`의 한 종류이므로 관련성이 있습니다.

8. 이중 타입 단언

TypeScript의 검사를 우회하기 위해 `unknown`을 중간에 넣는 방법이 있습니다.

const value =
  "TypeScript" as unknown as number;

과정은 다음과 같습니다.

string
↓
unknown
↓
number

`unknown`은 모든 값을 받을 수 있으므로 중간 다리 역할을 합니다.

이런 방식을 이중 타입 단언(Double Assertion)이라고 부릅니다.

문법적으로 가능하지만 매우 위험합니다.

const user =
  {} as unknown as User;

TypeScript의 거의 모든 안전 검사를 개발자가 강제로 통과시킬 수 있기 때문입니다.

이중 단언을 사용한다는 것은 다음과 비슷합니다.

“TypeScript야, 네가 의심하는 건 알겠지만 일단 두 번 믿어줘.”

두 번 주장한다고 사실이 되는 것은 아닙니다.

목소리를 크게 낸다고 빈 객체에 `name` 속성이 생기지도 않습니다. 📣

9. 이중 타입 단언이 필요한 상황

이중 타입 단언은 일반적인 애플리케이션 코드에서는 피하는 것이 좋습니다.

다만 다음과 같은 제한적인 상황에서는 등장할 수 있습니다.

  • 오래된 라이브러리의 잘못된 타입 정의를 우회할 때
  • 테스트용 가짜 객체를 만들 때
  • 프레임워크 내부에서 복잡한 타입 변환을 처리할 때
  • 현재 TypeScript가 표현하지 못하는 관계를 개발자가 확실히 알고 있을 때

테스트 객체 예시를 살펴보겠습니다.

type ApiClient = {
  getUser: (
    id: number
  ) => Promise<User>;

  deleteUser: (
    id: number
  ) => Promise<void>;
};

테스트에서는 `getUser`만 필요할 수 있습니다.

const mockClient = {
  getUser: async () => ({
    id: 1,
    name: "테스트 사용자",
  }),
} as ApiClient;

하지만 실제 타입의 필수 기능이 빠져 있습니다.

더 좋은 방법은 테스트에 필요한 타입만 별도로 만드는 것입니다.

type UserReader = {
  getUser: (
    id: number
  ) => Promise<User>;
};
const mockClient:
  UserReader = {
  getUser: async () => ({
    id: 1,
    name: "테스트 사용자",
  }),
};

단언으로 억지로 통과시키기보다 필요한 계약을 작게 만드는 편이 안전합니다.

10. `unknown`을 단언하기 전에 확인하자

다음 데이터는 외부에서 들어왔습니다.

const data: unknown = {
  id: 1,
  name: "김타입",
};

가장 짧은 방법은 바로 단언하는 것입니다.

const user =
  data as User;

하지만 실제 구조를 확인하지 않았습니다.

더 안전한 방법은 검사 함수를 사용하는 것입니다.

type User = {
  id: number;
  name: string;
};
function isUser(
  value: unknown
): value is User {
  if (
    typeof value !== "object" ||
    value === null
  ) {
    return false;
  }

  if (
    !("id" in value) ||
    !("name" in value)
  ) {
    return false;
  }

  return (
    typeof value.id === "number" &&
    typeof value.name === "string"
  );
}

검사한 뒤 사용합니다.

if (isUser(data)) {
  console.log(
    data.name.toUpperCase()
  );
}

이 경우 단언이 필요하지 않습니다.

TypeScript가 검사 결과를 이해하고 `data`를 `User`로 좁혀줍니다.

안전한 우선순위는 다음과 같습니다.

1. 타입을 정확하게 선언한다.
2. 조건문으로 타입을 확인한다.
3. 타입 가드로 검증한다.
4. 정말 필요한 경우에만 타입 단언을 사용한다.

11. 단언 대신 변수 타입을 선언할 수 있다

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

const user = {
  id: 1,
  name: "김타입",
} as User;

이 코드는 객체를 `User`로 단언합니다.

하지만 객체를 만들 때 타입을 확인받고 싶은 것이라면 변수 타입을 직접 선언하는 편이 안전합니다.

const user: User = {
  id: 1,
  name: "김타입",
};

속성이 빠지면 TypeScript가 오류를 알려줍니다.

const user: User = {
  id: 1,
};

`name`이 없으므로 오류가 발생합니다.

단언 방식도 일부 오류를 발견할 수 있지만, 기본적으로 개발자의 주장을 더 신뢰하는 방향입니다.

const user =
  {} as User;

이 코드는 통과할 수 있습니다.

따라서 새 객체를 직접 작성할 때는 타입 단언보다 타입 선언을 우선하는 것이 좋습니다.

권장
const user: User = {...}

신중하게 사용
const user = {...} as User

12. 함수 반환 타입으로 검사받기

함수가 사용자 객체를 반환한다고 가정해 보겠습니다.

function createUser() {
  return {
    id: 1,
    name: "김타입",
  } as User;
}

함수 내부에서 단언하기보다 반환 타입을 작성할 수 있습니다.

function createUser(): User {
  return {
    id: 1,
    name: "김타입",
  };
}

반환 객체에 문제가 있으면 TypeScript가 알려줍니다.

function createUser(): User {
  return {
    id: 1,
  };
}

`name`이 없으므로 오류입니다.

반환 타입은 함수의 결과를 검사하는 계약서 역할을 합니다.

단언은 계약서를 검사하지 않고 도장을 먼저 찍는 방식이 될 수 있습니다.

13. DOM에서 타입 단언이 자주 필요한 이유

브라우저의 DOM API는 다양한 종류의 HTML 요소를 반환할 수 있습니다.

const element =
  document.querySelector(
    "#user-name"
  );

`querySelector()`는 선택자 문자열만 보고 실제 HTML 요소의 종류를 완벽하게 알기 어렵습니다.

Element | null

하지만 `<input>` 요소의 `value` 속성을 사용하려면 `HTMLInputElement` 타입이 필요합니다.

console.log(
  element.value
);

일반 `Element` 타입에는 `value`가 없으므로 오류가 발생합니다.

타입 단언을 사용할 수 있습니다.

const inputElement =
  document.querySelector(
    "#user-name"
  ) as HTMLInputElement;
console.log(
  inputElement.value
);

하지만 여기에는 두 가지 위험이 있습니다.

실제 요소가 input이 아닐 수 있다.
요소를 찾지 못해 null일 수 있다.

단언 하나로 두 위험이 모두 사라진 것처럼 보일 수 있습니다.

실제 위험은 그대로 남아 있습니다.

14. DOM 요소를 단언만으로 처리하는 위험

다음 HTML이 있다고 가정해 보겠습니다.

<div id="user-name">
  김타입
</div>

그런데 코드에서는 input이라고 단언했습니다.

const inputElement =
  document.querySelector(
    "#user-name"
  ) as HTMLInputElement;

TypeScript는 `value` 사용을 허용합니다.

console.log(
  inputElement.value
);

하지만 실제 요소는 `<div>`입니다.

예상한 값이 나오지 않거나 이후 로직에서 문제가 생길 수 있습니다.

요소가 아예 없을 수도 있습니다.

const inputElement =
  document.querySelector(
    "#not-found"
  ) as HTMLInputElement;

실제 값은 `null`이지만 TypeScript는 `HTMLInputElement`라고 믿습니다.

inputElement.focus();

실행 중 오류가 발생합니다.

15. DOM 요소를 안전하게 다루는 첫 번째 방법

타입 단언 후 `null`을 확인할 수 있습니다.

const inputElement =
  document.querySelector(
    "#user-name"
  ) as HTMLInputElement | null;
if (
  inputElement !== null
) {
  console.log(
    inputElement.value
  );
}

이 방법은 요소가 없을 가능성을 타입에 유지합니다.

HTMLInputElement | null

TypeScript에게 지나친 확신을 주지 않는 방식입니다.

16. DOM 요소를 안전하게 다루는 두 번째 방법

`instanceof`로 실제 요소 타입을 확인할 수 있습니다.

const element =
  document.querySelector(
    "#user-name"
  );
if (
  element instanceof
  HTMLInputElement
) {
  console.log(
    element.value
  );
}

조건문 안에서 TypeScript는 `element`를 `HTMLInputElement`로 좁힙니다.

이 방법은 두 가지를 동시에 확인합니다.

요소가 존재하는가?
실제 HTMLInputElement인가?

DOM 요소에서는 단언보다 `instanceof` 검사가 더 안전한 경우가 많습니다.

17. 제네릭을 지원하는 DOM 메서드 활용하기

`querySelector()`에 기대하는 요소 타입을 전달할 수도 있습니다.

const inputElement =
  document
    .querySelector<HTMLInputElement>(
      "#user-name"
    );

결과 타입은 다음과 같습니다.

HTMLInputElement | null

따라서 `null`만 확인하면 됩니다.

if (
  inputElement !== null
) {
  console.log(
    inputElement.value
  );
}

선택적 연결 연산자를 사용할 수도 있습니다.

inputElement?.focus();

이 방식은 타입 단언보다 의도가 명확합니다.

“무조건 input이다.”
→ 타입 단언

“input을 찾고 있으며, 없을 수도 있다.”
→ 제네릭 + null 가능성

18. 이벤트 대상에서 타입 단언하기

브라우저 이벤트에서 `event.target`은 넓은 타입으로 취급될 수 있습니다.

function handleInput(
  event: Event
): void {
  console.log(
    event.target.value
  );
}

`event.target`에는 `value` 속성이 있다고 보장할 수 없으므로 오류가 발생합니다.

단언을 사용할 수 있습니다.

function handleInput(
  event: Event
): void {
  const target =
    event.target
      as HTMLInputElement;

  console.log(
    target.value
  );
}

더 안전하게 검사할 수도 있습니다.

function handleInput(
  event: Event
): void {
  if (
    event.target instanceof
    HTMLInputElement
  ) {
    console.log(
      event.target.value
    );
  }
}

이벤트가 항상 input에서 발생한다고 확신할 수 있다면 단언을 사용할 수 있습니다.

재사용 가능한 이벤트 함수라면 실제 타입 확인이 더 안전합니다.

19. `!` 비어 있지 않음 단언 연산자

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

const button =
  document.querySelector(
    "#save-button"
  );

`button`의 타입에는 `null`이 포함됩니다.

Element | null

따라서 바로 사용할 수 없습니다.

button.addEventListener(
  "click",
  () => {
    console.log("저장");
  }
);

TypeScript는 다음과 같이 경고합니다.

'button' is possibly 'null'.

개발자가 버튼이 반드시 존재한다고 확신한다면 `!`를 사용할 수 있습니다.

button!.addEventListener(
  "click",
  () => {
    console.log("저장");
  }
);

이 `!`를 비어 있지 않음 단언 연산자(Non-null Assertion Operator)라고 합니다.

TypeScript에게 다음과 같이 말합니다.

“이 값은 `null`이나 `undefined`가 아니야.”

20. `!`는 값을 만들어 주지 않는다

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

const button =
  document.querySelector(
    "#not-found"
  );

실제 결과는 `null`입니다.

하지만 `!`를 사용했습니다.

button!.addEventListener(
  "click",
  () => {}
);

TypeScript 오류는 사라집니다.

실행 중 오류는 사라지지 않습니다.

TypeError:
Cannot read properties of null

`!`는 `null`을 HTML 요소로 바꾸지 않습니다.

TypeScript의 경고만 제거합니다.

!가 하는 일
→ null과 undefined 가능성을 타입에서 제거

!가 하지 않는 일
→ 실제 값 확인
→ null을 정상 값으로 변환
→ 누락된 요소 생성

`!`는 낭떠러지 앞의 경고 표지판을 치우는 도구입니다.

다리를 건설해 주는 도구가 아닙니다. 🌉

21. `!`보다 null 확인을 우선하자

다음 코드는 짧습니다.

button!.addEventListener(
  "click",
  handleClick
);

하지만 요소가 없으면 오류가 발생합니다.

안전한 방식은 존재 여부를 확인하는 것입니다.

if (button !== null) {
  button.addEventListener(
    "click",
    handleClick
  );
}

요소가 반드시 있어야 하는 프로그램이라면 명시적인 오류를 던질 수도 있습니다.

if (button === null) {
  throw new Error(
    "저장 버튼을 찾지 못했습니다."
  );
}

button.addEventListener(
  "click",
  handleClick
);

이 방식의 장점은 문제가 생겼을 때 원인을 정확하게 알 수 있다는 것입니다.

좋지 않은 오류
Cannot read properties of null

더 명확한 오류
저장 버튼을 찾지 못했습니다.

22. 값을 반환하는 보조 함수 만들기

DOM 요소를 반드시 찾아야 하는 상황이 반복된다면 보조 함수를 만들 수 있습니다.

function getRequiredElement<
  T extends Element
>(
  selector: string
): T {
  const element =
    document.querySelector<T>(
      selector
    );

  if (element === null) {
    throw new Error(
      `${selector} 요소를 찾지 못했습니다.`
    );
  }

  return element;
}

사용합니다.

const input =
  getRequiredElement<
    HTMLInputElement
  >("#user-name");

이제 `input`은 `HTMLInputElement`입니다.

console.log(input.value);

요소가 없다면 함수 내부에서 명확한 오류가 발생합니다.

이 패턴은 단언을 여러 곳에 흩뿌리지 않고 확인 로직을 한곳에 모을 수 있습니다.

23. `!`와 논리 부정 연산자를 구분하자

느낌표 `!`는 위치에 따라 역할이 다릅니다.

값 앞에 사용

const isLogin = true;

console.log(!isLogin);

이때는 논리 부정 연산자입니다.

true → false
false → true

값 뒤에 사용

button!.click();

이때는 비어 있지 않음 단언 연산자입니다.

Element | null
↓
Element라고 주장

두 문법은 같은 기호를 사용하지만 목적이 다릅니다.

!isLogin
button!

느낌표도 자리가 바뀌면 직업이 바뀝니다.

앞에서는 불리언 뒤집개, 뒤에서는 null 경고 제거기가 됩니다.

24. 선택적 연결 연산자와 `!` 비교하기

요소가 없을 때 아무 작업도 하지 않아도 된다면 `?.`를 사용할 수 있습니다.

button?.addEventListener(
  "click",
  handleClick
);

요소가 있으면 이벤트를 등록하고, 없으면 아무 일도 하지 않습니다.

`!`를 사용하면 반드시 있다고 가정합니다.

button!.addEventListener(
  "click",
  handleClick
);

차이는 다음과 같습니다.

button?.
없을 수 있음을 인정
없으면 작업하지 않음

button!
반드시 있다고 주장
실제로 없으면 런타임 오류

UI에서 선택적인 요소라면 `?.`가 적합할 수 있습니다.

프로그램 동작에 반드시 필요한 요소라면 존재 여부를 확인하고 명확한 오류를 던지는 편이 좋습니다.

25. 기본값과 `!` 비교하기

문자열이 없을 수 있다고 가정해 보겠습니다.

const userName:
  string | null = null;

`!`를 사용하면 문자열이라고 주장할 수 있습니다.

console.log(
  userName!.toUpperCase()
);

하지만 실제 값은 `null`이므로 오류가 발생합니다.

기본값을 사용하면 안전합니다.

const displayName =
  userName ?? "익명 사용자";
console.log(
  displayName.toUpperCase()
);

값이 없을 때 사용할 대체값이 있다면 `!`보다 `??`가 적합합니다.

값이 반드시 존재함
→ 확인 후 사용

값이 없을 수 있고 기본값이 있음
→ ??

값이 없을 수 있고 작업을 생략해도 됨
→ ?.

근거 없이 반드시 있다고 주장
→ ! 사용 주의

26. `as const`란?

다음 배열을 살펴보겠습니다.

const statuses = [
  "idle",
  "loading",
  "success",
];

TypeScript는 일반적으로 문자열 배열로 추론합니다.

string[]

배열은 수정할 수 있기 때문입니다.

statuses.push("error");

`as const`를 사용하면 값을 최대한 구체적이고 읽기 전용으로 추론합니다.

const statuses = [
  "idle",
  "loading",
  "success",
] as const;

추론 타입은 다음과 비슷합니다.

readonly [
  "idle",
  "loading",
  "success"
]

세 가지 변화가 생겼습니다.

일반 배열
→ 읽기 전용 튜플

string
→ 각 문자열 리터럴 타입

요소 수정 가능
→ 요소 수정 불가능

27. 문자열 변수에서 `as const`

다음 변수는 `let`으로 선언되어 일반 문자열 타입으로 추론됩니다.

let status = "loading";

타입:

string

다음처럼 단언할 수 있습니다.

let status =
  "loading" as const;

타입:

"loading"

하지만 `let`인데 `"loading"` 하나로 고정되므로 다른 값을 할당할 수 없습니다.

status = "success";

오류가 발생합니다.

이런 경우에는 보통 `const` 변수 선언이 더 자연스럽습니다.

const status =
  "loading";

`const` 변수도 `"loading"` 리터럴 타입으로 추론됩니다.

28. 객체에서 `as const`

일반 객체를 살펴보겠습니다.

const request = {
  method: "GET",
  timeout: 3000,
};

속성 타입은 일반적으로 다음처럼 넓게 추론됩니다.

{
  method: string;
  timeout: number;
}

속성을 변경할 수 있습니다.

request.method = "POST";
request.timeout = 5000;

`as const`를 사용해 보겠습니다.

const request = {
  method: "GET",
  timeout: 3000,
} as const;

추론 타입은 다음과 비슷합니다.

{
  readonly method: "GET";
  readonly timeout: 3000;
}

이제 속성을 변경할 수 없습니다.

request.method = "POST";
request.timeout = 5000;

모두 오류가 발생합니다.

`as const`는 객체의 각 속성을 최대한 구체적인 리터럴 타입과 읽기 전용 속성으로 만듭니다.

29. `as const`는 런타임 동결이 아니다

매우 중요한 차이가 있습니다.

const request = {
  method: "GET",
} as const;

TypeScript는 속성 변경을 막습니다.

request.method = "POST";

하지만 `as const`는 JavaScript 실행 중 객체를 실제로 동결하는 기능이 아닙니다.

컴파일 후에는 타입 정보가 사라집니다.

런타임에서 객체를 동결하려면 `Object.freeze()` 같은 기능을 고려할 수 있습니다.

const request =
  Object.freeze({
    method: "GET",
  });

정리하면 다음과 같습니다.

as const
TypeScript 타입 수준의 읽기 전용

Object.freeze()
JavaScript 실행 중 객체 변경 제한

둘은 비슷한 의도를 가질 수 있지만 작동하는 시점이 다릅니다.

30. `as const`로 리터럴 유니언 만들기

허용 가능한 테마 목록을 배열로 관리해 보겠습니다.

const themes = [
  "light",
  "dark",
  "system",
] as const;

배열 요소 타입을 추출할 수 있습니다.

type Theme =
  typeof themes[number];

결과는 다음과 같습니다.

type Theme =
  | "light"
  | "dark"
  | "system";

함수에 적용합니다.

function setTheme(
  theme: Theme
): void {
  console.log(
    `${theme} 테마를 적용합니다.`
  );
}
setTheme("dark");

허용되지 않은 값은 오류입니다.

setTheme("blue");

값 목록과 타입을 하나의 배열에서 관리할 수 있다는 장점이 있습니다.

themes 배열
→ 실제 값 목록

Theme 타입
→ 허용 가능한 값 목록

31. 일반 타입 단언과 `as const`의 차이

다음 두 코드를 비교해 보겠습니다.

일반 타입 단언

const method =
  "GET" as string;

타입을 더 넓은 `string`으로 판단하게 합니다.

`as const`

const method =
  "GET" as const;

타입을 정확한 `"GET"`으로 고정합니다.

객체에서도 차이가 있습니다.

const request =
  {
    method: "GET",
  } as {
    method: string;
  };

`method`는 변경 가능한 문자열입니다.

request.method = "POST";

`as const`를 사용하면 다음과 같습니다.

const request = {
  method: "GET",
} as const;

`method`는 읽기 전용 `"GET"`입니다.

request.method = "POST";

오류가 발생합니다.

일반 as
개발자가 원하는 타입으로 판단 변경

as const
현재 값과 구조를 최대한 구체적으로 고정

32. 타입 단언으로 객체의 오류를 숨기는 사례

상품 타입을 정의합니다.

type Product = {
  id: number;
  name: string;
  price: number;
};

다음 객체에는 `price`가 없습니다.

const product = {
  id: 1,
  name: "키보드",
} as Product;

타입 단언으로 오류가 사라질 수 있습니다.

하지만 실제 값은 여전히 `price`를 가지고 있지 않습니다.

console.log(
  product.price.toLocaleString()
);

실행 중 문제가 발생할 수 있습니다.

더 안전한 방식은 타입을 선언하는 것입니다.

const product: Product = {
  id: 1,
  name: "키보드",
  price: 120000,
};

속성을 빠뜨리면 TypeScript가 바로 알려줍니다.

타입 단언은 빨간 밑줄을 지우는 지우개가 아닙니다.

문제를 해결하지 않고 빨간 줄만 지우는 데 사용하면 코드가 조용히 위험해집니다.

33. `satisfies`란?

객체가 특정 타입 조건을 만족하는지 검사하면서도, 객체 자체의 구체적인 타입 추론을 유지하고 싶을 수 있습니다.

이럴 때 `satisfies`를 사용할 수 있습니다.

type AppConfig = {
  theme:
    | "light"
    | "dark";

  timeout: number;
};

설정 객체를 만듭니다.

const config = {
  theme: "dark",
  timeout: 3000,
} satisfies AppConfig;

`config`가 `AppConfig` 조건을 만족하는지 검사합니다.

속성이 빠지면 오류가 발생합니다.

const config = {
  theme: "dark",
} satisfies AppConfig;

잘못된 값도 오류입니다.

const config = {
  theme: "blue",
  timeout: 3000,
} satisfies AppConfig;

`satisfies`는 다음과 같이 말합니다.

“이 객체가 `AppConfig` 조건을 만족하는지 검사해 줘. 하지만 객체의 구체적인 정보는 가능한 한 유지해 줘.”

34. 타입 선언, 타입 단언, `satisfies` 비교하기

세 가지 방식을 비교해 보겠습니다.

타입 선언

const config: AppConfig = {
  theme: "dark",
  timeout: 3000,
};

객체를 `AppConfig` 타입으로 검사하고 변수 타입도 `AppConfig`가 됩니다.

타입 단언

const config = {
  theme: "dark",
  timeout: 3000,
} as AppConfig;

개발자가 `AppConfig`라고 주장합니다.

검사보다 개발자의 판단을 더 강하게 신뢰할 수 있습니다.

`satisfies`

const config = {
  theme: "dark",
  timeout: 3000,
} satisfies AppConfig;

조건을 검사하면서 객체의 구체적인 추론을 유지합니다.

정리하면 다음과 같습니다.

문법주된 목적
`: Type`변수의 타입을 지정
`as Type`TypeScript의 판단을 개발자가 수정
`satisfies Type`타입 조건을 만족하는지 검사
`as const`값과 구조를 구체적이고 읽기 전용으로 고정

35. `satisfies`가 오타를 찾는 예제

색상 이름별 코드를 관리해 보겠습니다.

type ColorName =
  | "primary"
  | "secondary"
  | "danger";
type ColorMap =
  Record<ColorName, string>;

설정 객체를 만듭니다.

const colors = {
  primary: "#0066ff",
  secondary: "#666666",
  danger: "#ff3333",
} satisfies ColorMap;

오타가 있으면 오류가 발생합니다.

const colors = {
  primary: "#0066ff",
  seconday: "#666666",
  danger: "#ff3333",
} satisfies ColorMap;

`secondary`를 `seconday`로 잘못 작성했습니다.

TypeScript가 출석부와 실제 참석자를 비교해 누락과 오타를 찾아줍니다.

36. `as const satisfies` 함께 사용하기

값을 읽기 전용 리터럴로 유지하면서 타입 조건도 검사할 수 있습니다.

type RouteConfig = {
  path: string;
  method:
    | "GET"
    | "POST";
};
const routes = [
  {
    path: "/users",
    method: "GET",
  },
  {
    path: "/users",
    method: "POST",
  },
] as const satisfies
  readonly RouteConfig[];

여기에서는 두 가지 역할이 결합됩니다.

as const
값과 배열 구조를 읽기 전용으로 고정

satisfies
각 객체가 RouteConfig 조건을 만족하는지 검사

잘못된 HTTP 메서드는 오류입니다.

const routes = [
  {
    path: "/users",
    method: "FETCH",
  },
] as const satisfies
  readonly RouteConfig[];

37. 타입 단언보다 타입 좁히기를 우선하자

다음 함수는 문자열 또는 숫자를 받습니다.

function formatValue(
  value: string | number
): string {
  return (
    value as string
  ).toUpperCase();
}

개발자는 문자열이라고 단언했지만 숫자가 들어올 수 있습니다.

formatValue(100);

실행 중 오류가 발생합니다.

타입 좁히기를 사용해야 합니다.

function formatValue(
  value: string | number
): string {
  if (
    typeof value === "string"
  ) {
    return value.toUpperCase();
  }

  return value.toFixed(2);
}

타입 좁히기는 실제 조건을 확인합니다.

타입 단언은 개발자의 주장에 의존합니다.

타입 좁히기
증거를 확인한 뒤 판단

타입 단언
개발자의 증언으로 판단

법정에서는 증거가 증언보다 강합니다.

TypeScript 코드에서도 마찬가지입니다. 🔍

38. `in` 연산자로 속성 확인하기

두 객체 타입을 만들어 보겠습니다.

type Admin = {
  name: string;
  permissions: string[];
};

type Customer = {
  name: string;
  points: number;
};

유니언 타입을 처리합니다.

function printUser(
  user: Admin | Customer
): void {
  if (
    "permissions" in user
  ) {
    console.log(
      user.permissions
    );

    return;
  }

  console.log(user.points);
}

타입 단언을 사용하지 않아도 됩니다.

잘못된 방식:

const admin =
  user as Admin;

console.log(
  admin.permissions
);

`user`가 실제로 `Customer`라면 문제가 발생할 수 있습니다.

속성 존재 여부를 확인하는 것이 더 안전합니다.

객체 타입을 좁히는 다양한 방법은 PART 4에서 본격적으로 다룰 예정입니다.

39. `instanceof`로 클래스 확인하기

날짜 또는 문자열을 받는 함수를 만들어 보겠습니다.

function formatDate(
  value: Date | string
): string {
  if (
    value instanceof Date
  ) {
    return value.toISOString();
  }

  return value;
}

`instanceof` 조건 안에서는 `value`가 `Date`로 좁혀집니다.

단언할 필요가 없습니다.

const date =
  value as Date;

실제 인스턴스 관계를 확인할 수 있다면 `instanceof`를 우선하는 것이 안전합니다.

40. 사용자 정의 타입 가드 활용하기

외부 데이터가 상품인지 확인해 보겠습니다.

type Product = {
  id: number;
  name: string;
  price: number;
};
function isProduct(
  value: unknown
): value is Product {
  if (
    typeof value !== "object" ||
    value === null
  ) {
    return false;
  }

  if (
    !("id" in value) ||
    !("name" in value) ||
    !("price" in value)
  ) {
    return false;
  }

  return (
    typeof value.id === "number" &&
    typeof value.name === "string" &&
    typeof value.price === "number"
  );
}

검사한 뒤 사용합니다.

const data: unknown = {
  id: 1,
  name: "키보드",
  price: 120000,
};

if (isProduct(data)) {
  console.log(
    data.price.toLocaleString()
  );
}

타입 가드는 실제 구조를 검사하고 TypeScript의 타입도 좁혀줍니다.

외부 데이터에는 단순 단언보다 훨씬 안전합니다.

41. API 응답에는 런타임 검증이 필요하다

다음 코드는 API 응답을 `User`로 단언합니다.

const response =
  await fetch("/api/user");

const data =
  await response.json()
    as User;

TypeScript는 `data`를 `User`로 취급합니다.

하지만 서버가 다음 데이터를 보냈을 수도 있습니다.

{
  "message": "로그인이 필요합니다."
}

타입 단언은 서버 응답을 바꾸지 않습니다.

안전하게 처리하려면 응답을 `unknown`으로 보고 검사합니다.

const data: unknown =
  await response.json();

if (!isUser(data)) {
  throw new Error(
    "잘못된 사용자 응답입니다."
  );
}

console.log(data.name);

실무에서는 검증 라이브러리를 사용해 스키마를 검사하기도 합니다.

중요한 원칙은 다음과 같습니다.

외부 데이터는 타입 단언만으로 신뢰하지 않는다.

네트워크 밖에서 들어온 택배는 송장에 “안전함”이라고 적혀 있어도 내용물을 확인해야 합니다. 📦

42. 로컬 스토리지 값 안전하게 처리하기

`localStorage`에서 가져온 값은 문자열 또는 `null`입니다.

const savedTheme =
  localStorage.getItem(
    "theme"
  );

타입:

string | null

다음처럼 바로 단언할 수 있습니다.

type Theme =
  | "light"
  | "dark"
  | "system";

const theme =
  savedTheme as Theme;

하지만 저장된 값이 `"banana"`일 수도 있습니다.

더 안전하게 검사합니다.

function isTheme(
  value: string
): value is Theme {
  return (
    value === "light" ||
    value === "dark" ||
    value === "system"
  );
}
const theme: Theme =
  savedTheme !== null &&
  isTheme(savedTheme)
    ? savedTheme
    : "system";

이제 잘못된 저장값에는 기본값이 사용됩니다.

43. 폼 입력값은 실제 변환이 필요하다

HTML 입력 요소의 `value`는 문자열입니다.

const ageInput =
  document
    .querySelector<HTMLInputElement>(
      "#age"
    );
const ageText =
  ageInput?.value;

숫자로 사용하고 싶다고 타입 단언해서는 안 됩니다.

const age =
  ageText as unknown as number;

실제 값은 문자열입니다.

숫자로 변환해야 합니다.

const age =
  Number(ageText);

변환 결과가 유효한지 검사합니다.

if (
  Number.isNaN(age)
) {
  console.log(
    "올바른 나이를 입력해 주세요."
  );
} else {
  console.log(
    `내년 나이: ${age + 1}`
  );
}

타입 단언과 타입 변환을 혼동하면 사용자의 나이에 문자열 `"1"`이 붙을 수 있습니다.

25 + 1
→ 26

"25" + 1
→ "251"

생일이 한 번에 226년 뒤로 날아갈 수 있습니다. 🎂

44. `as`를 연속으로 사용하는 코드는 점검하자

다음과 같은 코드가 반복된다면 설계를 다시 살펴볼 필요가 있습니다.

const user =
  data as User;

const product =
  response as Product;

const settings =
  value as Settings;

확인해야 할 질문은 다음과 같습니다.

처음부터 함수 반환 타입을 정확히 작성할 수 없는가?
외부 데이터 검증이 필요한 것은 아닌가?
유니언 타입과 타입 좁히기로 해결할 수 없는가?
객체를 생성할 때 타입 선언을 사용할 수 없는가?
라이브러리 타입 정의가 잘못된 것은 아닌가?

타입 단언이 코드 곳곳에 많다면 TypeScript가 정보를 충분히 받지 못하고 있다는 신호일 수 있습니다.

단언은 소금과 비슷합니다.

적절히 사용하면 코드의 맛을 살리지만, 모든 줄에 한 숟가락씩 넣으면 타입 시스템이 짜서 물을 찾습니다. 🧂

45. 안전한 타입 처리 우선순위

타입 문제가 발생했을 때 다음 순서로 해결해 보세요.

1단계: 타입을 정확히 선언한다

const user: User = {
  id: 1,
  name: "김타입",
};

2단계: 함수의 입력과 반환 타입을 작성한다

function createUser(): User {
  return {
    id: 1,
    name: "김타입",
  };
}

3단계: 유니언 타입과 조건문으로 좁힌다

if (
  typeof value === "string"
) {
  value.toUpperCase();
}

4단계: `in`, `instanceof`, 타입 가드를 사용한다

if (
  value instanceof Date
) {
  value.toISOString();
}

5단계: 기본값과 선택적 연결을 사용한다

const name =
  user.name ?? "이름 없음";

user.profile?.imageUrl;

6단계: 타입 조건 검사에는 `satisfies`를 고려한다

const config = {
  theme: "dark",
} satisfies Config;

7단계: 위 방법으로 표현하기 어려울 때 단언한다

const input =
  element as HTMLInputElement;

단언은 첫 번째 버튼이 아니라 마지막에 가까운 버튼입니다.

46. 타입 단언을 사용해도 괜찮은 상황

다음 조건을 대부분 만족한다면 타입 단언을 사용할 근거가 있습니다.

  • 개발자가 TypeScript보다 실제 상황을 더 정확히 알고 있다.
  • 값의 출처와 생성 과정을 통제하고 있다.
  • 런타임에서 해당 조건이 보장된다.
  • 단언 범위가 작고 지역적이다.
  • 단언이 틀렸을 때의 결과를 이해하고 있다.
  • 더 안전한 타입 좁히기 방법이 적절하지 않다.

예를 들어 테스트 코드에서 직접 생성한 DOM 요소를 다룰 수 있습니다.

const input =
  document.createElement(
    "input"
  );

const element:
  HTMLInputElement = input;

이 경우에는 단언조차 필요하지 않습니다.

반면 다음처럼 일반적인 선택자를 사용할 때는 확인이 필요합니다.

const element =
  document.querySelector(
    ".form-element"
  );

47. 타입 단언을 피해야 하는 상황

다음 상황에서는 단언을 매우 조심해야 합니다.

  • 외부 API 응답
  • 사용자가 입력한 데이터
  • `JSON.parse()` 결과
  • 로컬 스토리지 데이터
  • URL 쿼리 문자열
  • 메시지 큐나 웹소켓 데이터
  • 구조를 모르는 라이브러리 응답
  • 오류를 빨리 없애기 위한 임시 처방
  • 여러 번 이어지는 이중 단언

위 데이터는 실행 중 실제 값이 달라질 수 있습니다.

단언보다 검증이 필요합니다.

const data: unknown =
  JSON.parse(jsonText);

if (isUser(data)) {
  // 안전하게 사용
}

48. 실습 1: 안전한 DOM 입력 처리

HTML에 다음 입력창이 있다고 가정하겠습니다.

<input
  id="price"
  type="number"
/>

<button id="calculate">
  계산
</button>

요소를 찾습니다.

const priceInput =
  document
    .querySelector<HTMLInputElement>(
      "#price"
    );

const calculateButton =
  document
    .querySelector<HTMLButtonElement>(
      "#calculate"
    );

두 요소가 모두 존재하는지 확인합니다.

if (
  priceInput === null ||
  calculateButton === null
) {
  throw new Error(
    "계산에 필요한 요소가 없습니다."
  );
}

이벤트를 등록합니다.

calculateButton
  .addEventListener(
    "click",
    () => {
      const price =
        Number(
          priceInput.value
        );

      if (
        Number.isNaN(price)
      ) {
        console.log(
          "올바른 가격을 입력해 주세요."
        );

        return;
      }

      console.log(
        `부가세 포함 가격: ${
          price * 1.1
        }원`
      );
    }
  );

이 예제에서는 다음 원칙을 적용했습니다.

DOM 요소 타입
→ querySelector 제네릭 사용

요소 존재 여부
→ null 검사

입력 문자열을 숫자로 사용
→ Number()로 실제 변환

잘못된 숫자
→ Number.isNaN() 검사

단언으로 경고만 지우지 않고 단계별로 실제 값을 확인했습니다.

49. 실습 2: 사용자 설정 검사하기

설정 타입을 정의합니다.

type Theme =
  | "light"
  | "dark"
  | "system";

type UserSettings = {
  theme: Theme;
  fontSize: number;
};

테마 검사 함수를 만듭니다.

function isTheme(
  value: unknown
): value is Theme {
  return (
    value === "light" ||
    value === "dark" ||
    value === "system"
  );
}

설정 검사 함수를 만듭니다.

function isUserSettings(
  value: unknown
): value is UserSettings {
  if (
    typeof value !== "object" ||
    value === null
  ) {
    return false;
  }

  if (
    !("theme" in value) ||
    !("fontSize" in value)
  ) {
    return false;
  }

  return (
    isTheme(value.theme) &&
    typeof value.fontSize ===
      "number"
  );
}

로컬 스토리지에서 값을 읽습니다.

const savedSettings =
  localStorage.getItem(
    "settings"
  );

안전하게 처리합니다.

let settings: UserSettings = {
  theme: "system",
  fontSize: 16,
};

if (
  savedSettings !== null
) {
  const parsedData: unknown =
    JSON.parse(
      savedSettings
    );

  if (
    isUserSettings(
      parsedData
    )
  ) {
    settings = parsedData;
  }
}

단순히 다음처럼 단언하지 않았습니다.

const settings =
  JSON.parse(
    savedSettings
  ) as UserSettings;

외부 저장 데이터는 언제든 예상과 다를 수 있기 때문입니다.

50. 실습 3: `as const`로 상태 목록 만들기

상태 목록을 정의합니다.

const taskStatuses = [
  "waiting",
  "running",
  "completed",
  "failed",
] as const;

요소 타입으로 유니언을 만듭니다.

type TaskStatus =
  typeof taskStatuses[number];

상태 검사 함수를 작성합니다.

function isTaskStatus(
  value: unknown
): value is TaskStatus {
  return (
    typeof value === "string" &&
    taskStatuses.some(
      (status) =>
        status === value
    )
  );
}

외부 상태값을 검사합니다.

const externalStatus:
  unknown = "running";
if (
  isTaskStatus(
    externalStatus
  )
) {
  console.log(
    `현재 상태: ${
      externalStatus
    }`
  );
} else {
  console.log(
    "잘못된 상태입니다."
  );
}

`as const`를 사용해 실제 목록과 타입을 함께 관리했습니다.

51. 실습 4: `satisfies`로 메뉴 설정 검사하기

메뉴 타입을 정의합니다.

type MenuItem = {
  label: string;
  path: string;
  visible: boolean;
};

메뉴 배열을 만듭니다.

const menus = [
  {
    label: "홈",
    path: "/",
    visible: true,
  },
  {
    label: "마이페이지",
    path: "/mypage",
    visible: true,
  },
  {
    label: "관리자",
    path: "/admin",
    visible: false,
  },
] satisfies MenuItem[];

표시할 메뉴만 선택합니다.

const visibleMenus =
  menus.filter(
    (menu) =>
      menu.visible
  );

메뉴 링크를 출력합니다.

visibleMenus.forEach(
  (menu) => {
    console.log(
      `${menu.label}: ${menu.path}`
    );
  }
);

잘못된 속성 타입은 바로 발견됩니다.

const menus = [
  {
    label: "홈",
    path: "/",
    visible: "yes",
  },
] satisfies MenuItem[];

`visible`은 불리언이어야 하므로 오류가 발생합니다.

52. 실습 5: 반드시 존재하는 요소 함수

DOM 요소를 안전하게 가져오는 함수를 작성합니다.

function requireElement<
  T extends Element
>(
  selector: string
): T {
  const element =
    document
      .querySelector<T>(
        selector
      );

  if (element === null) {
    throw new Error(
      `필수 요소를 찾지 못했습니다: ${selector}`
    );
  }

  return element;
}

입력 요소를 가져옵니다.

const userNameInput =
  requireElement<
    HTMLInputElement
  >("#user-name");

버튼을 가져옵니다.

const submitButton =
  requireElement<
    HTMLButtonElement
  >("#submit");

이벤트를 등록합니다.

submitButton
  .addEventListener(
    "click",
    () => {
      const userName =
        userNameInput
          .value
          .trim();

      if (
        userName.length === 0
      ) {
        console.log(
          "이름을 입력해 주세요."
        );

        return;
      }

      console.log(
        `${userName}님, 환영합니다.`
      );
    }
  );

이제 코드 곳곳에서 `!`를 반복하지 않아도 됩니다.

존재 확인과 오류 처리가 한 함수 안에 모였습니다.

53. 자주 발생하는 실수

실수 1. 타입 단언을 타입 변환으로 생각하기

const value =
  "100" as unknown as number;

console.log(
  value + 1
);

실제 값은 문자열이므로 예상과 다른 결과가 나올 수 있습니다.

숫자로 변환합니다.

const value =
  Number("100");

실수 2. 외부 데이터를 바로 단언하기

const user =
  JSON.parse(text)
    as User;

실제 구조는 검증되지 않습니다.

const data: unknown =
  JSON.parse(text);

if (isUser(data)) {
  // 안전하게 사용
}

실수 3. 빈 객체를 완성된 객체로 단언하기

const user =
  {} as User;

`id`와 `name`이 실제로 없습니다.

정상적인 초기값을 만듭니다.

const user: User = {
  id: 0,
  name: "",
};

또는 값이 아직 없다면 `null`로 표현합니다.

let user:
  User | null = null;

실수 4. 모든 null 경고를 `!`로 제거하기

user!.profile!.imageUrl!;

경고는 사라지지만 실행 중 오류 가능성은 커집니다.

선택적 연결과 기본값을 사용합니다.

const imageUrl =
  user
    ?.profile
    ?.imageUrl
  ?? "/default.png";

실수 5. DOM 요소가 반드시 있다고 가정하기

const button =
  document.querySelector(
    "#save"
  )!;

button.addEventListener(
  "click",
  handleSave
);

HTML 구조가 바뀌면 오류가 발생합니다.

const button =
  document.querySelector(
    "#save"
  );

if (button === null) {
  throw new Error(
    "저장 버튼이 없습니다."
  );
}

실수 6. `as const`가 객체를 런타임에서 동결한다고 생각하기

const config = {
  theme: "dark",
} as const;

`as const`는 TypeScript 타입 수준의 제한입니다.

실행 중 동결이 필요하다면 별도 런타임 처리를 고려해야 합니다.

실수 7. 타입 선언 대신 불필요한 단언 사용하기

const product = {
  id: 1,
  name: "키보드",
  price: 120000,
} as Product;

직접 만드는 객체라면 다음 방식이 더 명확합니다.

const product: Product = {
  id: 1,
  name: "키보드",
  price: 120000,
};

실수 8. 오류를 없애기 위해 이중 단언 사용하기

const numberValue =
  text as unknown as number;

오류 메시지를 없애는 것이 목적이라면 잘못된 접근일 가능성이 큽니다.

실제 변환이나 검증 방법을 찾아야 합니다.

const numberValue =
  Number(text);

실수 9. `satisfies`를 타입 변환으로 생각하기

const config = {
  theme: "dark",
} satisfies Config;

`satisfies`는 값을 `Config`로 변환하지 않습니다.

해당 객체가 `Config` 조건을 만족하는지 검사합니다.

54. 타입 단언 판단 공식

타입 단언을 작성하기 전에 다음 질문을 확인해 보세요.

질문 1. TypeScript보다 내가 더 많은 정보를 알고 있는가?

그렇다면 단언을 고려할 수 있습니다.

element as HTMLInputElement

질문 2. 실제 값이 해당 타입이라는 근거가 있는가?

HTML 구조, 라이브러리 계약, 직접 생성한 값처럼 확실한 근거가 필요합니다.

근거가 없다면 검사해야 합니다.

질문 3. 타입 선언으로 해결할 수 있는가?

const user: User = {
  id: 1,
  name: "김타입",
};

가능하다면 단언보다 타입 선언을 사용합니다.

질문 4. 타입 좁히기로 해결할 수 있는가?

if (
  typeof value === "string"
) {
  // 안전하게 사용
}

가능하다면 타입 좁히기를 우선합니다.

질문 5. 외부 데이터인가?

외부 데이터라면 단언보다 런타임 검증이 필요합니다.

const data: unknown =
  await response.json();

질문 6. `!` 없이 처리할 수 있는가?

value?.property
value ?? defaultValue

또는 명시적인 확인을 사용합니다.

질문 7. 값 목록을 고정하려는 것인가?

일반 단언보다 `as const`가 적합할 수 있습니다.

const statuses = [
  "idle",
  "loading",
] as const;

질문 8. 타입 조건을 검사하려는 것인가?

`satisfies`를 고려합니다.

const config = {
  theme: "dark",
} satisfies Config;

55. 미니 퀴즈

문제 1

다음 코드에서 타입 단언은 실제 값을 숫자로 변환할까요?

const value =
  "100" as unknown as number;

정답

아닙니다.

실제 값은 여전히 문자열 `"100"`입니다.

숫자로 변환하려면 `Number()`를 사용해야 합니다.

const value =
  Number("100");

문제 2

다음 코드의 위험은 무엇일까요?

const user =
  {} as User;

console.log(
  user.name.toUpperCase()
);

정답

실제 객체에 `name` 속성이 없습니다.

타입 단언은 누락된 속성을 만들어 주지 않으므로 실행 중 오류가 발생할 수 있습니다.

문제 3

다음 두 문법은 같은 의미일까요?

value as string
<string>value

정답

기본적으로 같은 타입 단언입니다.

다만 TSX에서는 꺾쇠괄호 문법이 JSX와 충돌할 수 있어 `as` 문법을 주로 사용합니다.

문제 4

다음 코드에서 `!`의 의미는 무엇일까요?

button!.click();

정답

`button`이 `null`이나 `undefined`가 아니라고 TypeScript에게 단언합니다.

실제 값의 존재 여부를 검사하지는 않습니다.

문제 5

다음 코드의 더 안전한 대안은 무엇일까요?

button!.click();

정답 예시

if (button !== null) {
  button.click();
}

요소가 없을 때 작업을 생략해도 된다면 다음도 가능합니다.

button?.click();

문제 6

다음 코드에서 `as const`를 사용하면 어떤 변화가 생길까요?

const status = [
  "idle",
  "loading",
] as const;

정답

일반 `string[]`이 아니라 읽기 전용 리터럴 튜플로 추론됩니다.

readonly [
  "idle",
  "loading"
]

문제 7

다음 중 새 객체를 만들 때 더 안전한 방식은 무엇일까요?

const user =
  {} as User;
const user: User = {
  id: 1,
  name: "김타입",
};

정답

두 번째 방식입니다.

타입 선언을 사용하면 필수 속성과 속성 타입을 제대로 검사받을 수 있습니다.

문제 8

외부 API 응답을 처리하는 더 안전한 시작 타입은 무엇일까요?

const data: ______ =
  await response.json();

정답 예시

const data: unknown =
  await response.json();

이후 실제 구조를 검증합니다.

문제 9

다음 문법의 목적은 무엇일까요?

const config = {
  theme: "dark",
} satisfies Config;

정답

객체가 `Config` 타입 조건을 만족하는지 검사하면서 객체 자체의 구체적인 타입 추론을 유지하는 것입니다.

문제 10

타입 단언보다 먼저 고려해야 할 방법 두 가지를 작성해 보세요.

정답 예시

타입을 정확하게 선언한다.
조건문이나 타입 가드로 타입을 좁힌다.

56. 핵심 정리

타입 단언

개발자가 TypeScript에게 값의 타입을 직접 알려줍니다.

const text =
  value as string;

타입 단언은 값 변환이 아니다

const value =
  "100" as unknown as number;

실제 값은 여전히 문자열입니다.

const value =
  Number("100");

실제 변환에는 변환 함수를 사용합니다.

`as` 문법을 주로 사용

value as Type

TSX와의 호환성 때문에 꺾쇠괄호 방식보다 일반적으로 편리합니다.

외부 데이터는 검증 후 사용

const data: unknown =
  JSON.parse(text);

if (isUser(data)) {
  console.log(data.name);
}

`!` 비어 있지 않음 단언

button!.click();

`null`과 `undefined` 가능성을 타입에서 제거하지만 실제 값을 검사하지 않습니다.

안전한 대안

button?.click();
if (button !== null) {
  button.click();
}

`as const`

값과 구조를 구체적인 리터럴 타입과 읽기 전용으로 고정합니다.

const statuses = [
  "idle",
  "loading",
] as const;

`satisfies`

타입 조건을 만족하는지 검사합니다.

const config = {
  theme: "dark",
} satisfies Config;

안전한 처리 우선순위

정확한 타입 선언
↓
타입 추론 활용
↓
조건문과 타입 좁히기
↓
타입 가드와 런타임 검증
↓
기본값, ?., ??
↓
satisfies
↓
필요한 경우에만 타입 단언

57. 마무리

타입 단언은 TypeScript의 판단이 부족할 때 개발자가 추가 정보를 전달하는 기능입니다.

const input =
  element as HTMLInputElement;

개발자가 실제 상황을 더 정확히 알고 있다면 유용합니다.

하지만 단언은 실제 값을 확인하지 않습니다.

const user =
  {} as User;

빈 객체는 단언 후에도 빈 객체입니다.

const value =
  "100" as unknown as number;

문자열은 단언 후에도 문자열입니다.

`!` 역시 실제 값을 만들어 주지 않습니다.

button!.click();

버튼이 없다면 실행 중 오류가 발생합니다.

`as const`는 조금 다른 역할을 합니다.

const statuses = [
  "idle",
  "loading",
  "success",
] as const;

현재 값과 구조를 최대한 구체적인 타입으로 고정합니다.

`satisfies`는 값이 타입 조건을 만족하는지 검사합니다.

const config = {
  theme: "dark",
  timeout: 3000,
} satisfies AppConfig;

이번 편의 핵심 원칙은 다음과 같습니다.

TypeScript의 경고를 지우기 위해 단언하지 말고, TypeScript가 모르는 정보를 전달하기 위해 단언하자.

타입 단언은 TypeScript와 벌이는 힘겨루기가 아닙니다.

개발자가 가진 추가 정보를 전달하는 협업 도구입니다.

근거 있는 확신은 코드의 빈틈을 채우지만, 근거 없는 확신은 오류 위에 예쁜 카펫을 깔아놓습니다.

카펫 아래의 구멍은 사라지지 않습니다. 🕳️

가능하면 값을 확인하고, 타입을 좁히고, 구조를 검증하세요.

그리고 정말 개발자가 더 정확히 알고 있는 순간에만 TypeScript에게 말하면 됩니다.

“이 부분은 내가 확인했어. 이제 믿어도 괜찮아.”

58. 다음 편 예고

지금까지 우리는 TypeScript의 기본 타입과 여러 데이터를 표현하는 방법을 살펴봤습니다.

const userName: string =
  "김타입";

const age: number =
  25;

const skills: string[] = [
  "JavaScript",
  "TypeScript",
];

객체도 사용해 봤습니다.

const user = {
  id: 1,
  name: "김타입",
  age: 25,
};

하지만 객체의 구조를 여러 곳에서 반복하려면 어떻게 해야 할까요?

const firstUser: {
  id: number;
  name: string;
  age: number;
} = {
  id: 1,
  name: "김타입",
  age: 25,
};
const secondUser: {
  id: number;
  name: string;
  age: number;
} = {
  id: 2,
  name: "이컴파일",
  age: 30,
};

같은 구조를 계속 작성하면 코드가 길어지고 수정도 어려워집니다.

객체 구조에 이름을 붙일 수는 없을까요?

type User = {
  id: number;
  name: string;
  age: number;
};

이제 `User`라는 설계도를 여러 객체에 사용할 수 있습니다.

const firstUser: User = {
  id: 1,
  name: "김타입",
  age: 25,
};

const secondUser: User = {
  id: 2,
  name: "이컴파일",
  age: 30,
};

속성을 생략할 수 있게 만들거나, 수정하지 못하게 설정하는 방법도 있습니다.

type User = {
  readonly id: number;
  name: string;
  nickname?: string;
};

다음 편부터는 PART 3 객체와 사용자 정의 타입이 시작됩니다.

변수 하나의 타입을 넘어 실제 애플리케이션의 데이터 구조를 설계하는 단계로 들어갑니다.

다음 이야기

[TypeScript 완전정복 #12] 객체에도 설계도가 필요하다 | 객체 타입과 type 별칭 제대로 이해하기

  • 객체 타입은 어떻게 작성할까요?
  • 객체의 속성 타입은 어떻게 지정할까요?
  • 같은 객체 구조를 반복하지 않으려면 어떻게 해야 할까요?
  • `type` 별칭은 무엇일까요?
  • 선택적 속성 `?`는 언제 사용할까요?
  • 읽기 전용 속성 `readonly`는 무엇일까요?
  • 객체 안의 객체는 어떻게 표현할까요?
  • 메서드를 가진 객체 타입은 어떻게 작성할까요?
  • 초과 속성 검사는 왜 발생할까요?
  • 객체 타입을 실무 데이터 구조로 설계하려면 어떻게 해야 할까요?

다음 편에서는 객체의 구조를 타입으로 설계하고, 반복되는 데이터 모양에 `User`, `Product`, `Order` 같은 이름을 붙이는 방법을 살펴보겠습니다.

댓글

0

댓글을 불러오는 중입니다.