목록으로

프로그래밍 · TypeScript

TypeScript 완전정복 10화: any, unknown, void, never 이해하기

BeanCon
TypeScript any, unknown, void, never 타입의 차이와 활용법을 설명하는 대표 이미지

TypeScript 완전정복 10화입니다. 타입 검사를 포기하거나 더 엄격하게 지키는 특별한 타입인 any, unknown, void, never를 입문자 눈높이에서 정리했습니다. any의 의미와 위험성, 암묵적 any, any 사용 범위 줄이기, unknown으로 외부 데이터 안전하게 처리하기, typeof와 배열·객체 검사, JSON.parse와 catch 오류 처리, void 함수와 콜백의 의미, never 함수와 완전성 검사, 네 타입의 선택 기준과 실습 예제까지 다룹니다.

목차

any, unknown, void, never 완벽 이해하기

지난 시간에는 하나의 변수에 여러 타입을 허용하는 유니언 타입과, 정해진 값만 허용하는 리터럴 타입을 살펴봤습니다.

type UserId =
  | string
  | number;

type RequestStatus =
  | "idle"
  | "loading"
  | "success"
  | "error";

TypeScript는 여러 가능성을 허용하면서도 허용 범위를 타입으로 제한할 수 있었습니다.

그런데 개발하다 보면 타입을 정확하게 알 수 없는 값도 만나게 됩니다.

const response = getExternalData();

외부 API에서 어떤 데이터가 돌아올지 확신할 수 없을 수도 있습니다.

사용자가 직접 입력한 JSON을 처리해야 할 수도 있습니다.

const parsedData =
  JSON.parse(userInput);

기존 JavaScript 프로젝트를 TypeScript로 바꾸는 과정에서 아직 타입을 정의하지 못한 코드도 있을 수 있습니다.

이럴 때 가장 먼저 떠오르는 타입이 있습니다.

any

`any`는 어떤 값이든 받아줍니다.

let data: any;

data = "TypeScript";
data = 100;
data = true;
data = {};
data = [1, 2, 3];

심지어 존재하지 않는 메서드를 호출해도 TypeScript가 조용할 수 있습니다.

data.flyToTheMoon();

`any`는 TypeScript 세계의 자유이용권입니다.

문제는 놀이기구뿐 아니라 직원 전용 통로와 비상구까지 열어준다는 것입니다. 🎟️

그렇다면 어떤 값이든 받을 수 있으면서, 사용하기 전에는 반드시 타입을 확인하게 만드는 방법은 없을까요?

unknown

반환값이 없는 함수에는 어떤 타입을 사용해야 할까요?

void

오류를 던지거나 무한 반복해서 절대로 호출한 곳으로 돌아오지 않는 함수는 어떻게 표현할까요?

never

이번 시간에는 TypeScript의 특별한 역할을 맡은 네 가지 타입을 알아봅니다.

any     : 무엇이든 받고 무엇이든 허용
unknown : 무엇이든 받지만 확인 전에는 사용 금지
void    : 함수의 반환값을 사용하지 않음
never   : 값이나 정상적인 함수 종료가 절대 발생하지 않음

네 타입은 이름부터 조금 독특합니다.

하지만 역할을 정확히 이해하면 TypeScript의 안전장치를 어디에서 켜고 꺼야 하는지 분명하게 판단할 수 있습니다.

1. 이번 시간에 배울 내용

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

  • `any` 타입은 무엇인가?
  • `any`를 사용하면 타입 검사가 어떻게 달라지는가?
  • `any`가 다른 코드로 퍼지는 이유
  • `any`를 사용해도 되는 제한적인 상황
  • `unknown` 타입은 무엇인가?
  • `unknown`과 `any`의 결정적인 차이
  • `typeof`로 `unknown` 값을 확인하는 방법
  • 배열과 객체 형태를 안전하게 확인하는 방법
  • `catch`에서 오류를 안전하게 처리하는 방법
  • `void` 타입은 무엇인가?
  • `void`와 `undefined`의 차이
  • 콜백 함수에서 `void`가 의미하는 것
  • `never` 타입은 무엇인가?
  • 오류를 던지는 함수와 무한 반복 함수
  • 모든 경우를 처리했는지 확인하는 완전성 검사
  • `any`, `unknown`, `void`, `never` 선택 기준

오늘의 네 등장인물을 미리 소개하겠습니다.

any
“검사 없이 모두 통과하세요.”

unknown
“통과는 가능하지만 짐 검사는 받아야 합니다.”

void
“업무는 끝났지만 가져온 결과물은 없습니다.”

never
“이 열차는 출발하지만 돌아오지 않습니다.”

이제 타입 시스템의 특별 대응팀을 하나씩 만나보겠습니다. 🧭

2. `any`란?

`any`는 어떤 타입의 값이든 저장할 수 있는 타입입니다.

let value: any;

문자열을 저장할 수 있습니다.

value = "TypeScript";

숫자도 저장할 수 있습니다.

value = 100;

불리언, 배열, 객체, 함수도 저장할 수 있습니다.

value = true;
value = [1, 2, 3];
value = {
  name: "김타입",
};
value = () => {
  console.log("함수입니다.");
};

여기까지만 보면 유니언 타입을 매우 크게 만든 것처럼 보입니다.

type LargeUnion =
  | string
  | number
  | boolean
  | object
  | Function;

하지만 `any`는 단순히 여러 타입을 허용하는 것보다 훨씬 강력합니다.

그리고 그만큼 위험합니다.

`any` 값에는 거의 어떤 작업이든 시도할 수 있기 때문입니다.

value.toUpperCase();
value.toFixed(2);
value.push(100);
value.notExistingMethod();

TypeScript는 `value`의 실제 타입을 확인하지 않고 대부분의 코드를 통과시킵니다.

공식 TypeScript 가이드에서도 `any`는 특정 값 때문에 타입 검사 오류를 피하고 싶을 때 사용할 수 있지만, 타입 검사가 비활성화된다는 점을 설명합니다.

3. `any`를 사용하면 TypeScript가 조용해진다

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

let userName: string =
  "김타입";

userName.toFixed(2);

문자열에는 `toFixed()` 메서드가 없으므로 TypeScript가 오류를 알려줍니다.

하지만 타입을 `any`로 바꾸면 어떻게 될까요?

let userName: any =
  "김타입";

userName.toFixed(2);

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

하지만 프로그램을 실행하면 문제가 발생합니다.

TypeError:
userName.toFixed is not a function

`any`는 오류를 해결한 것이 아닙니다.

오류가 발생할 장소를 컴파일 단계에서 실행 단계로 옮겼을 뿐입니다.

TypeScript 오류 표시
→ 사라짐

실제 코드 오류
→ 그대로 존재

경고등에 검은 테이프를 붙였다고 엔진 문제가 고쳐지는 것은 아닙니다.

대시보드만 평화로워졌을 뿐, 자동차 안에서는 여전히 무언가 끓고 있습니다. 🚗

4. `any`는 자동완성도 약하게 만든다

타입이 명확하면 VS Code는 사용할 수 있는 속성과 메서드를 알려줍니다.

const language: string =
  "TypeScript";

language.

점 `.`을 입력하면 문자열 메서드를 추천받을 수 있습니다.

toUpperCase
toLowerCase
includes
slice
trim

객체 타입도 마찬가지입니다.

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

const user: User = {
  id: 1,
  name: "김타입",
  email: "type@example.com",
};

user.

개발 도구는 다음 속성을 알고 있습니다.

id
name
email

하지만 `any`를 사용하면 정확한 구조를 알 수 없습니다.

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

user.

TypeScript가 구조를 모르기 때문에 자동완성과 오타 검사도 약해집니다.

console.log(
  user.naem
);

`name`을 `naem`으로 잘못 작성했지만 TypeScript가 잡아내지 못할 수 있습니다.

`any`를 사용하는 순간 TypeScript가 안경을 벗습니다.

코드는 보이지만 글자가 흐릿해지고, 자동완성은 기억력에 의존하기 시작합니다. 👓

5. `any`와 유니언 타입의 차이

다음 두 타입을 비교해 보겠습니다.

let firstValue: any;

let secondValue:
  string | number;

둘 다 문자열과 숫자를 저장할 수 있습니다.

firstValue = "TypeScript";
firstValue = 100;

secondValue = "TypeScript";
secondValue = 100;

하지만 불리언을 저장할 때 차이가 생깁니다.

firstValue = true;

`any`이므로 허용됩니다.

secondValue = true;

`string | number`에는 불리언이 포함되어 있지 않으므로 오류가 발생합니다.

메서드 사용에서도 차이가 있습니다.

firstValue.toUpperCase();

`any`는 대부분 허용합니다.

secondValue.toUpperCase();

`secondValue`가 숫자일 수 있으므로 오류가 발생합니다.

타입을 확인해야 합니다.

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

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

구분`any`유니언 타입
허용 값거의 모든 값지정된 타입만
타입 확인거의 요구하지 않음필요한 경우 요구
자동완성약해질 수 있음타입별로 제공
오류 발견실행 단계로 미뤄질 수 있음컴파일 단계에서 발견
안전성낮음높음

6. `any`는 코드 사이로 퍼질 수 있다

`any`의 위험성은 해당 변수 하나에서 끝나지 않습니다.

다른 변수나 함수 결과로 전달되면서 타입 안전성을 약화시킬 수 있습니다.

let externalData: any = {
  name: "김타입",
};

다른 변수에 저장합니다.

const user = externalData;

`user`도 `any`로 취급될 수 있습니다.

user.name.toUpperCase();
user.age.toFixed(2);
user.fly();

함수에 전달하면 함수 내부에도 영향을 줄 수 있습니다.

function printUser(user: any) {
  console.log(
    user.naem.toUpperCase()
  );
}

속성 이름에 오타가 있지만 TypeScript가 찾아내지 못합니다.

`any`는 잉크 한 방울과 비슷합니다.

한곳에서만 사용했다고 생각했는데 데이터를 따라 이동하며 코드 여러 곳을 흐리게 만들 수 있습니다. 🫗

7. 암묵적인 `any`

개발자가 직접 `any`를 작성하지 않아도 타입이 암묵적으로 `any`가 될 수 있습니다.

다음 함수를 살펴보겠습니다.

function greet(name) {
  console.log(
    `안녕하세요, ${name}님`
  );
}

매개변수 `name`에 타입이 없습니다.

엄격한 설정이 꺼져 있다면 `name`이 암묵적인 `any`로 처리될 수 있습니다.

따라서 여러 종류의 값이 전달될 수 있습니다.

greet("김타입");
greet(100);
greet({});

이를 방지하려면 `tsconfig.json`에서 `noImplicitAny`를 활성화할 수 있습니다.

{
  "compilerOptions": {
    "noImplicitAny": true
  }
}

또는 전체 엄격 모드를 활성화합니다.

{
  "compilerOptions": {
    "strict": true
  }
}

이제 타입이 없는 매개변수에 오류가 표시됩니다.

Parameter 'name'
implicitly has an 'any' type.

타입을 직접 작성합니다.

function greet(
  name: string
) {
  console.log(
    `안녕하세요, ${name}님`
  );
}

8. `any`를 사용하게 되는 대표적인 상황

`any`를 무조건 금지한다고 모든 문제가 해결되는 것은 아닙니다.

때로는 현실적인 이유로 `any`가 필요할 수 있습니다.

기존 JavaScript 프로젝트를 변환할 때

규모가 큰 JavaScript 프로젝트를 한 번에 TypeScript로 바꾸기 어려울 수 있습니다.

function processLegacyData(
  data: any
) {
  // 기존 코드
}

우선 컴파일이 가능하도록 만든 뒤 타입을 점진적으로 개선할 수 있습니다.

타입 정의가 없는 외부 라이브러리를 사용할 때

오래된 라이브러리나 자체 제작 라이브러리에 타입 정보가 없을 수 있습니다.

declare const legacyLibrary: any;

매우 동적인 데이터를 임시로 처리할 때

구조가 계속 달라지는 데이터를 조사하는 초기 단계에서는 임시로 사용할 수 있습니다.

const debugData: any =
  getTemporaryData();

하지만 데이터 구조를 확인했다면 구체적인 타입으로 교체하는 것이 좋습니다.

type DebugData = {
  id: number;
  message: string;
};

핵심은 다음과 같습니다.

`any`는 최종 설계가 아니라 임시 다리로 사용하는 것이 좋습니다.

공사가 끝났는데도 임시 철판 다리를 계속 사용하면 언젠가 누군가 발을 헛디딜 수 있습니다.

9. `any` 사용 범위를 줄이는 방법

프로젝트 전체를 `any`로 만들 필요는 없습니다.

외부 데이터가 들어오는 경계에서만 제한적으로 사용하고, 내부에서는 구체적인 타입으로 변환할 수 있습니다.

function receiveData(
  rawData: any
): User {
  return {
    id: Number(rawData.id),
    name: String(rawData.name),
  };
}
type User = {
  id: number;
  name: string;
};

이후 코드에서는 `User` 타입을 사용합니다.

const user =
  receiveData(externalData);

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

더 안전하게 처리하려면 `any` 대신 이번에 배울 `unknown`을 사용할 수 있습니다.

10. `unknown`이란?

`unknown`은 타입을 알 수 없는 값을 나타냅니다.

let value: unknown;

`any`처럼 어떤 값이든 저장할 수 있습니다.

value = "TypeScript";
value = 100;
value = true;
value = {};
value = [1, 2, 3];

여기까지만 보면 `any`와 같습니다.

차이는 값을 사용할 때 나타납니다.

value.toUpperCase();

오류가 발생합니다.

value.toFixed(2);

이 코드도 오류입니다.

value.name;

속성 접근도 바로 할 수 없습니다.

TypeScript는 `unknown` 값의 실제 타입을 확인하기 전에는 대부분의 작업을 허용하지 않습니다.

공식 문서는 `unknown`을 타입을 알 수 없는 값에 사용할 수 있는 타입으로 소개하며, `typeof` 검사나 비교, 타입 가드 등을 통해 구체적인 타입으로 좁힌 뒤 사용하도록 설명합니다. 또한 `unknown`은 `any`보다 안전한 대응 타입으로 도입되었습니다.

11. `any`와 `unknown`의 결정적인 차이

다음 두 변수를 비교해 보겠습니다.

let anyValue: any =
  "TypeScript";

let unknownValue: unknown =
  "TypeScript";

`any`에는 문자열 메서드를 바로 호출할 수 있습니다.

anyValue.toUpperCase();

존재하지 않는 메서드도 호출을 시도할 수 있습니다.

anyValue.fly();

반면 `unknown`에는 바로 메서드를 호출할 수 없습니다.

unknownValue.toUpperCase();

오류가 발생합니다.

먼저 타입을 확인해야 합니다.

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

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

any
무엇이든 받을 수 있다.
확인하지 않고 사용할 수 있다.

unknown
무엇이든 받을 수 있다.
확인하기 전에는 사용할 수 없다.

`any`가 검문소가 없는 국경이라면, `unknown`은 입국은 허용하지만 여권 확인을 반드시 거치는 국경입니다. 🛂

12. `unknown`은 안전한 외부 데이터 입구다

외부에서 들어오는 데이터의 타입은 확신하기 어렵습니다.

function receiveExternalData():
  unknown {
  return getExternalData();
}

반환값을 받습니다.

const data =
  receiveExternalData();

바로 사용할 수는 없습니다.

console.log(
  data.toUpperCase()
);

실제 타입을 확인합니다.

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

숫자라면 숫자 기능을 사용합니다.

if (
  typeof data === "number"
) {
  console.log(
    data.toFixed(2)
  );
}

불리언도 확인할 수 있습니다.

if (
  typeof data === "boolean"
) {
  console.log(
    data ? "참" : "거짓"
  );
}

`unknown`을 사용하면 타입 검증을 생략하기 어렵습니다.

불편해 보이지만 그 불편함이 안전장치입니다.

13. `unknown` 값에 허용되는 작업

`unknown` 값에는 모든 작업이 금지되는 것은 아닙니다.

다른 값과 비교할 수 있습니다.

function checkValue(
  value: unknown
) {
  if (value === null) {
    console.log(
      "null입니다."
    );
  }
}

타입을 확인할 수 있습니다.

console.log(
  typeof value
);

조건문을 통해 구체적인 타입으로 좁힐 수 있습니다.

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

하지만 타입을 확인하지 않은 속성 접근과 메서드 호출은 제한됩니다.

value.length;
value.name;
value.toUpperCase();

TypeScript가 우리를 괴롭히기 위해 막는 것이 아닙니다.

현재 값이 문자열인지, 숫자인지, 객체인지 아무도 모르기 때문에 잠시 사용을 보류하는 것입니다.

14. `typeof`로 `unknown` 확인하기

기본 타입은 `typeof`로 확인할 수 있습니다.

function formatValue(
  value: unknown
): string {
  if (
    typeof value === "string"
  ) {
    return value.trim();
  }

  if (
    typeof value === "number"
  ) {
    return value.toFixed(2);
  }

  if (
    typeof value === "boolean"
  ) {
    return value
      ? "true"
      : "false";
  }

  return "지원하지 않는 타입";
}

호출해 보겠습니다.

console.log(
  formatValue(" TypeScript ")
);

console.log(
  formatValue(100)
);

console.log(
  formatValue(true)
);

console.log(
  formatValue({})
);

출력 결과:

TypeScript
100.00
true
지원하지 않는 타입

타입을 확인한 각 조건문 안에서는 전용 기능을 안전하게 사용할 수 있습니다.

15. `null`도 별도로 확인해야 한다

객체를 확인할 때는 `null`에 주의해야 합니다.

function printObject(
  value: unknown
) {
  if (
    typeof value === "object"
  ) {
    console.log(
      "객체입니다."
    );
  }
}

문제는 다음 값도 조건을 통과한다는 것입니다.

printObject(null);

JavaScript에서 `typeof null`의 결과는 `"object"`이기 때문입니다.

따라서 실제 객체인지 확인하려면 `null`을 제외해야 합니다.

function printObject(
  value: unknown
) {
  if (
    typeof value === "object" &&
    value !== null
  ) {
    console.log(
      "null이 아닌 객체입니다."
    );
  }
}

객체 검문소에서는 신분증과 함께 “혹시 `null`은 아닌가?”라는 추가 질문이 필요합니다.

16. 배열인지 확인하기

배열도 `typeof` 결과가 `"object"`입니다.

console.log(
  typeof [1, 2, 3]
);

출력 결과:

object

배열 여부는 `Array.isArray()`로 확인할 수 있습니다.

function printArray(
  value: unknown
) {
  if (
    Array.isArray(value)
  ) {
    console.log(
      `배열 길이: ${value.length}`
    );
  }
}

호출합니다.

printArray([
  10,
  20,
  30,
]);

출력 결과:

배열 길이: 3

`Array.isArray()` 조건 안에서 TypeScript는 값을 배열로 다룰 수 있습니다.

하지만 배열 요소의 정확한 타입까지 자동으로 보장되는 것은 아닙니다.

17. 문자열 배열인지 확인하기

외부 값이 문자열 배열인지 확인해 보겠습니다.

function isStringArray(
  value: unknown
): value is string[] {
  return (
    Array.isArray(value) &&
    value.every(
      (item) =>
        typeof item === "string"
    )
  );
}

이 함수는 다음 내용을 검사합니다.

값이 배열인가?
↓
배열의 모든 요소가 문자열인가?

사용해 보겠습니다.

const data: unknown = [
  "JavaScript",
  "TypeScript",
];
if (
  isStringArray(data)
) {
  data.forEach(
    (language) => {
      console.log(
        language.toUpperCase()
      );
    }
  );
}

`value is string[]` 문법은 사용자 정의 타입 가드입니다.

타입 가드는 PART 4에서 자세히 다룰 예정입니다.

이번에는 외부 데이터의 모양을 직접 검사할 수 있다는 흐름만 확인하면 됩니다.

18. `unknown` 객체의 속성은 바로 읽을 수 없다

다음 값이 객체처럼 보이더라도 타입이 `unknown`이면 속성을 바로 읽을 수 없습니다.

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

다음 코드는 오류가 발생합니다.

console.log(
  data.name
);

먼저 객체인지 확인해야 합니다.

if (
  typeof data === "object" &&
  data !== null
) {
  // 객체라는 사실만 확인됨
}

하지만 이것만으로 `name` 속성이 존재한다는 사실까지 알 수는 없습니다.

`in` 연산자를 함께 사용할 수 있습니다.

if (
  typeof data === "object" &&
  data !== null &&
  "name" in data
) {
  console.log(
    data.name
  );
}

속성의 값이 문자열인지도 확인할 수 있습니다.

if (
  typeof data === "object" &&
  data !== null &&
  "name" in data &&
  typeof data.name === "string"
) {
  console.log(
    data.name.toUpperCase()
  );
}

외부 객체를 사용할 때는 건물만 확인하는 것으로 부족합니다.

원하는 방이 실제로 있는지, 그 방 안에 어떤 물건이 있는지까지 확인해야 합니다.

19. 안전한 사용자 검사 함수 만들기

사용자 타입을 정의합니다.

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

어떤 값이 `User`인지 확인하는 함수를 만들어 보겠습니다.

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"
  );
}

외부 데이터를 준비합니다.

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

검사한 뒤 사용합니다.

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

검사가 끝난 조건문 안에서 `externalData`는 `User` 타입으로 취급됩니다.

20. `JSON.parse()`와 타입 안전성

다음 코드는 JSON 문자열을 객체로 변환합니다.

const jsonText = `
  {
    "id": 1,
    "name": "김타입"
  }
`;
const data =
  JSON.parse(jsonText);

`JSON.parse()`의 결과는 전통적으로 `any`로 취급되기 때문에 다음 코드가 통과할 수 있습니다.

console.log(
  data.naem.toUpperCase()
);

`name`을 `naem`으로 잘못 작성했지만 TypeScript가 막지 못할 수 있습니다.

더 안전하게 사용하려면 결과를 `unknown` 변수에 담고 검증합니다.

const data: unknown =
  JSON.parse(jsonText);
if (
  isUser(data)
) {
  console.log(
    data.name.toUpperCase()
  );
} else {
  console.log(
    "올바른 사용자 데이터가 아닙니다."
  );
}

중요한 점은 타입을 작성한다고 실제 데이터가 자동으로 검증되는 것은 아니라는 것입니다.

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

이 코드는 TypeScript에게 `User`라고 주장할 뿐, 실행 중인 JSON의 구조를 검사하지 않습니다.

외부 데이터에는 실제 검증 로직이 필요합니다.

21. 함수 매개변수에 `unknown` 사용하기

어떤 값이든 출력할 수 있는 함수를 만들어 보겠습니다.

function printValue(
  value: unknown
): void {
  if (
    typeof value === "string"
  ) {
    console.log(
      `문자열: ${value}`
    );

    return;
  }

  if (
    typeof value === "number"
  ) {
    console.log(
      `숫자: ${value}`
    );

    return;
  }

  if (
    typeof value === "boolean"
  ) {
    console.log(
      `불리언: ${value}`
    );

    return;
  }

  console.log(
    "기타 타입입니다."
  );
}

어떤 값이든 전달할 수 있습니다.

printValue("TypeScript");
printValue(100);
printValue(true);
printValue({
  name: "김타입",
});

함수는 모든 값을 받지만 내부에서 타입을 확인한 뒤 처리합니다.

이것이 `unknown`의 대표적인 사용 방식입니다.

22. `unknown`은 다른 타입 변수에 바로 넣을 수 없다

다음 변수를 살펴보겠습니다.

let unknownValue: unknown =
  "TypeScript";

let message: string;

`unknownValue`를 `message`에 바로 할당할 수 없습니다.

message = unknownValue;

실제 값이 문자열인지 확실하지 않기 때문입니다.

타입을 확인한 뒤 할당합니다.

if (
  typeof unknownValue === "string"
) {
  message = unknownValue;
}

반면 `any`는 바로 할당할 수 있습니다.

let anyValue: any = 100;

let text: string =
  anyValue;

실제 값은 숫자인데 문자열 변수에 들어갔습니다.

이처럼 `unknown`은 데이터를 받는 것은 넓게 허용하고, 밖으로 꺼내는 것은 엄격하게 제한합니다.

23. `any`와 `unknown` 할당 관계

다음 코드는 가능합니다.

let unknownValue: unknown;

unknownValue = "문자열";
unknownValue = 100;
unknownValue = true;

모든 타입의 값은 `unknown`에 할당할 수 있습니다.

하지만 `unknown` 값은 구체적인 타입에 바로 할당할 수 없습니다.

let text: string =
  unknownValue;

`any`에는 양방향 이동이 비교적 자유롭습니다.

let anyValue: any;

anyValue = "문자열";

let numberValue: number =
  anyValue;

공식 TypeScript 호환성 문서는 `any`와 `unknown` 모두 다양한 값을 받을 수 있지만, `unknown`은 구체적인 타입에 바로 할당할 수 없다는 차이를 설명합니다.

24. `catch`문의 오류는 어떤 타입일까?

오류를 처리할 때 다음 코드를 자주 사용합니다.

try {
  throw new Error(
    "문제가 발생했습니다."
  );
} catch (error) {
  console.log(
    error.message
  );
}

하지만 JavaScript에서는 반드시 `Error` 객체만 던지는 것은 아닙니다.

문자열도 던질 수 있습니다.

throw "문자열 오류";

숫자도 던질 수 있습니다.

throw 404;

객체를 던질 수도 있습니다.

throw {
  code: 500,
  message: "서버 오류",
};

따라서 `catch`의 `error`가 항상 `Error`라고 가정하면 위험합니다.

최근 TypeScript의 엄격한 설정에서는 `catch` 변수를 `unknown`으로 취급하도록 구성할 수 있으며, 이는 오류를 사용하기 전에 타입을 확인하도록 만듭니다.

25. `catch` 오류를 안전하게 처리하기

`instanceof`를 사용해 `Error` 객체인지 확인할 수 있습니다.

try {
  throw new Error(
    "파일을 찾을 수 없습니다."
  );
} catch (error: unknown) {
  if (
    error instanceof Error
  ) {
    console.log(
      error.message
    );
  } else {
    console.log(
      "알 수 없는 오류입니다."
    );
  }
}

문자열 오류도 별도로 처리할 수 있습니다.

try {
  throw "문자열 오류";
} catch (error: unknown) {
  if (
    error instanceof Error
  ) {
    console.log(
      error.message
    );
  } else if (
    typeof error === "string"
  ) {
    console.log(error);
  } else {
    console.log(
      "처리할 수 없는 오류입니다."
    );
  }
}

`unknown`을 사용하면 오류 처리 코드도 현실적인 모든 가능성을 고려하게 됩니다.

26. `useUnknownInCatchVariables`

`tsconfig.json`에서 다음 옵션을 사용할 수 있습니다.

{
  "compilerOptions": {
    "useUnknownInCatchVariables": true
  }
}

`strict` 모드를 활성화한 프로젝트에서는 관련 엄격한 검사가 함께 적용될 수 있습니다.

{
  "compilerOptions": {
    "strict": true
  }
}

`catch` 변수에 안전하게 접근하려면 타입을 확인합니다.

try {
  // 오류 발생 가능 코드
} catch (error) {
  if (
    error instanceof Error
  ) {
    console.log(
      error.message
    );
  }
}

오류 처리 구역은 프로그램에서 예상하지 못한 값이 가장 자주 출몰하는 장소입니다.

`unknown`은 이곳에 세워두기 좋은 안전 펜스입니다.

27. 실무에서 `any`보다 `unknown`을 우선해야 하는 이유

외부 데이터를 받는 두 함수를 비교해 보겠습니다.

`any` 사용

function handleData(
  data: any
) {
  console.log(
    data.name.toUpperCase()
  );
}

아무 값이나 전달할 수 있습니다.

handleData(100);

TypeScript가 오류를 막지 못할 수 있습니다.

`unknown` 사용

function handleData(
  data: unknown
) {
  if (
    typeof data === "object" &&
    data !== null &&
    "name" in data &&
    typeof data.name === "string"
  ) {
    console.log(
      data.name.toUpperCase()
    );
  }
}

값을 사용하기 전에 구조를 확인합니다.

handleData(100);

함수는 오류 없이 숫자를 무시하거나 별도로 처리할 수 있습니다.

다음 기준을 기억하면 좋습니다.

타입을 정말 포기해야 한다
→ any

타입은 모르지만 안전하게 확인하겠다
→ unknown

대부분의 외부 데이터 처리에서는 `unknown`이 더 안전한 출발점입니다.

28. `void`란?

`void`는 주로 함수가 의미 있는 반환값을 제공하지 않는다는 사실을 나타냅니다.

function printMessage(): void {
  console.log(
    "메시지를 출력합니다."
  );
}

함수는 화면에 메시지를 출력하지만 계산 결과나 데이터를 반환하지 않습니다.

printMessage();

다음 함수도 `void` 반환 타입을 가질 수 있습니다.

function saveLog(
  message: string
): void {
  console.log(
    `[LOG] ${message}`
  );
}
function showAlert(
  message: string
): void {
  alert(message);
}

TypeScript 공식 문서에서는 반환값이 없는 함수의 타입을 표현할 때 `void`를 사용하며, `void`와 `undefined`는 동일한 타입이 아니라고 설명합니다.

29. `void` 함수는 무엇을 하는가?

`void` 함수도 여러 작업을 수행할 수 있습니다.

function updateScreen(): void {
  console.log(
    "데이터를 읽습니다."
  );

  console.log(
    "화면을 갱신합니다."
  );

  console.log(
    "로그를 기록합니다."
  );
}

함수 내부 작업이 없다는 뜻이 아닙니다.

외부에 사용할 반환값이 없다는 뜻입니다.

void 함수

작업 수행 가능
로그 출력 가능
상태 변경 가능
다른 함수 호출 가능
의미 있는 반환값은 제공하지 않음

`void`는 일을 하지 않는 직원이 아닙니다.

업무는 처리하지만 보고서 상자를 들고 돌아오지 않는 직원입니다. 📭

30. `void` 함수 호출 결과

다음 함수를 호출해 보겠습니다.

function printMessage(): void {
  console.log(
    "안녕하세요."
  );
}

반환 결과를 변수에 저장합니다.

const result =
  printMessage();

실행 결과로 `undefined`가 관찰될 수 있습니다.

console.log(result);

출력:

undefined

하지만 타입의 관점에서 `void`는 단순히 `undefined` 값 하나를 뜻한다기보다, 호출자가 반환값을 사용하지 않아야 한다는 함수의 계약에 가깝습니다.

const result: void =
  printMessage();

`result`로 의미 있는 계산을 하려고 하면 문제가 됩니다.

result.toString();

31. `void`와 `undefined`의 차이

두 함수의 반환 타입을 비교해 보겠습니다.

function firstFunction(): void {
  console.log(
    "첫 번째 함수"
  );
}
function secondFunction():
  undefined {
  return undefined;
}

`void`는 함수의 반환값을 사용하지 않는다는 의미로 주로 사용합니다.

`undefined` 반환 타입은 함수가 실제로 `undefined`라는 값을 반환한다는 점을 더 구체적으로 표현합니다.

void
반환값을 호출자가 사용하지 않음

undefined
반환되는 값이 undefined임

대부분의 일반적인 부수 효과 함수에는 `void`를 사용합니다.

function printLog(): void {
  console.log(
    "로그입니다."
  );
}

32. `void` 함수에서 `return` 사용하기

`void` 함수에서도 값을 반환하지 않는 `return`을 사용할 수 있습니다.

function printUser(
  userName: string | null
): void {
  if (
    userName === null
  ) {
    return;
  }

  console.log(userName);
}

`return`은 함수 실행을 즉시 종료합니다.

userName이 null
→ 함수 종료

userName이 문자열
→ 출력 진행

이 방식을 빠른 반환 또는 조기 반환이라고 부릅니다.

하지만 의미 있는 값을 반환하는 것은 반환 타입과 맞지 않습니다.

function printMessage(): void {
  return "완료";
}

일반적인 함수 선언에서 명시적으로 `void`를 반환한다고 했다면 문자열을 반환할 수 없습니다.

33. 반환 타입을 생략하면 `void`가 추론된다

반환값이 없는 함수는 TypeScript가 `void`로 추론할 수 있습니다.

function printMessage() {
  console.log(
    "메시지입니다."
  );
}

추론된 반환 타입:

void

직접 작성해도 됩니다.

function printMessage(): void {
  console.log(
    "메시지입니다."
  );
}

간단한 내부 함수에서는 추론에 맡길 수 있습니다.

하지만 공용 함수나 함수의 역할을 명확히 보여주고 싶다면 반환 타입을 작성할 수 있습니다.

function writeAuditLog(
  message: string
): void {
  console.log(message);
}

34. 화살표 함수에서 `void`

화살표 함수에도 `void` 반환 타입을 작성할 수 있습니다.

const printMessage =
  (message: string): void => {
    console.log(message);
  };

이벤트 처리 함수에서도 자주 볼 수 있습니다.

const handleClick =
  (): void => {
    console.log(
      "버튼이 클릭되었습니다."
    );
  };

배열의 `forEach()` 콜백도 반환값을 사용하지 않습니다.

const numbers = [
  10,
  20,
  30,
];

numbers.forEach(
  (number): void => {
    console.log(number);
  }
);

대부분은 반환 타입을 생략해도 `void`로 추론됩니다.

numbers.forEach(
  (number) => {
    console.log(number);
  }
);

35. `() => void`의 의미

함수 타입을 만들 수 있습니다.

type ClickHandler =
  () => void;

이 타입은 매개변수가 없고 호출자가 반환값을 사용하지 않는 함수를 의미합니다.

const handleClick:
  ClickHandler = () => {
    console.log(
      "클릭했습니다."
    );
  };

매개변수가 있는 함수 타입도 만들 수 있습니다.

type MessageHandler =
  (message: string) => void;
const handleMessage:
  MessageHandler =
  (message) => {
    console.log(message);
  };

이벤트 처리, 콜백 함수, 로그 함수에서 자주 사용합니다.

36. 콜백 타입의 `void`는 반환값을 무시한다는 뜻

다음 함수 타입을 살펴보겠습니다.

type Callback =
  () => void;

이 타입은 콜백 구현이 내부적으로 절대로 값을 만들 수 없다는 뜻으로만 해석하면 곤란합니다.

호출하는 쪽에서 반환값을 사용하지 않겠다는 의미가 중요합니다.

예를 들어 배열의 `forEach()`를 살펴보겠습니다.

const numbers = [
  1,
  2,
  3,
];

const results: number[] = [];

다음 코드는 동작할 수 있습니다.

numbers.forEach(
  (number) =>
    results.push(number)
);

`push()`는 배열의 새 길이를 숫자로 반환합니다.

하지만 `forEach()`는 콜백의 반환값을 사용하지 않습니다.

push()가 숫자를 반환
↓
forEach()는 그 숫자를 무시

다만 함수를 직접 `: void`로 선언하고 값을 반환하는 것과, 반환값을 무시하는 콜백 위치에 함수를 전달하는 것은 구분해야 합니다.

37. `void` 변수는 일반적으로 만들지 않는다

다음 코드는 문법상 표현할 수 있지만 실용성이 거의 없습니다.

let emptyValue: void;

엄격한 설정에서는 일반적으로 `undefined` 정도만 관련될 수 있습니다.

emptyValue = undefined;

문자열이나 숫자를 저장할 수는 없습니다.

emptyValue = "TypeScript";
emptyValue = 100;

`void`는 변수 타입보다 함수의 반환 타입으로 사용하는 것이 자연스럽습니다.

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

38. `never`란?

`never`는 절대로 발생하지 않는 값의 타입입니다.

let impossibleValue: never;

이 변수에는 문자열을 저장할 수 없습니다.

impossibleValue = "TypeScript";

숫자도 저장할 수 없습니다.

impossibleValue = 100;

불리언이나 `null`, `undefined`도 일반적으로 저장할 수 없습니다.

impossibleValue = true;
impossibleValue = null;
impossibleValue = undefined;

`never`는 저장할 값이 없는 타입입니다.

함수에서는 정상적으로 호출한 곳으로 돌아오지 않는다는 의미로 사용합니다.

공식 문서에서 `never`는 발생하지 않는 값의 타입이며, 항상 오류를 던지거나 끝나지 않아 정상적인 종료 지점에 도달하지 않는 함수의 반환 타입으로 설명됩니다.

39. 오류를 던지는 함수와 `never`

다음 함수는 항상 오류를 던집니다.

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

함수를 호출합니다.

throwError(
  "치명적인 오류가 발생했습니다."
);

`throw`가 실행되면 함수는 호출한 곳으로 정상적인 값을 반환하지 않습니다.

다음 코드는 실행되지 않습니다.

throwError(
  "오류 발생"
);

console.log(
  "이 코드는 실행되지 않습니다."
);

함수가 빈손으로 돌아오는 것도 아닙니다.

함수 자체가 돌아오지 않습니다.

void
돌아오지만 사용할 값이 없음

never
정상적인 경로로 돌아오지 않음

40. 무한 반복 함수와 `never`

끝나지 않는 함수도 `never` 반환 타입을 가질 수 있습니다.

function runForever(): never {
  while (true) {
    console.log(
      "계속 실행 중입니다."
    );
  }
}

이 함수는 `return`을 만나지 않습니다.

반복문이 영원히 끝나지 않기 때문입니다.

실제 프로그램에서는 서버 프로세스, 이벤트 루프, 지속적인 감시 작업을 설명하는 특수한 상황에서 관련 개념을 만날 수 있습니다.

다만 무한 반복 예제를 직접 실행하면 프로그램이 멈추지 않을 수 있으므로 주의해야 합니다.

`never` 열차는 종착역 안내 방송이 없습니다.

철로가 끝없이 이어집니다. 🚂

41. `void`와 `never` 비교하기

두 함수를 비교해 보겠습니다.

function printMessage(): void {
  console.log(
    "메시지입니다."
  );
}

이 함수는 작업을 마치고 호출한 곳으로 돌아옵니다.

printMessage();

console.log(
  "다음 코드 실행"
);

다음 로그가 실행됩니다.

반면 `never` 함수는 정상적으로 돌아오지 않습니다.

function fail(
  message: string
): never {
  throw new Error(message);
}
fail(
  "오류가 발생했습니다."
);

console.log(
  "이 코드는 실행되지 않음"
);

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

구분`void``never`
함수 종료정상 종료정상 종료하지 않음
반환값사용하지 않음반환 자체가 발생하지 않음
대표 사례로그 출력, 화면 갱신오류 던지기, 무한 반복
호출 이후 코드실행 가능일반적으로 도달하지 못함

42. `never`와 `undefined`도 다르다

다음 함수는 `undefined`를 반환합니다.

function getNothing():
  undefined {
  return undefined;
}

함수는 정상적으로 끝납니다.

const result =
  getNothing();

console.log(result);

반면 `never` 함수는 값을 반환하지 않습니다.

function stopProgram(): never {
  throw new Error(
    "프로그램 중단"
  );
}
undefined
함수가 정상적으로 끝나며
undefined라는 값이 존재함

never
함수가 정상적으로 끝나지 않으며
반환값이 존재하지 않음

`undefined`는 빈 봉투를 들고 돌아오는 것입니다.

`never`는 배달원이 출발했지만 도착하지 않는 것입니다.

43. 유니언 타입에서 `never`

`never`는 유니언 타입에 포함되어도 다른 타입과 합쳐지면 사라지는 것처럼 보입니다.

type Result =
  string | never;

이 타입은 사실상 다음과 같습니다.

type Result =
  string;

왜냐하면 `never`에 해당하는 값은 실제로 존재하지 않기 때문입니다.

다음 타입도 마찬가지입니다.

type Value =
  number | never;

사실상 `number`입니다.

문자열 또는 절대로 존재하지 않는 값
→ 결국 문자열

숫자 또는 절대로 존재하지 않는 값
→ 결국 숫자

빈 의자를 좌석 목록에 추가해도 실제 승객 수는 늘어나지 않습니다.

44. 조건문에서 `never`가 나타나는 순간

리터럴 유니언 타입을 만들어 보겠습니다.

type Direction =
  | "left"
  | "right";

모든 경우를 처리합니다.

function move(
  direction: Direction
): void {
  if (
    direction === "left"
  ) {
    console.log(
      "왼쪽으로 이동"
    );

    return;
  }

  if (
    direction === "right"
  ) {
    console.log(
      "오른쪽으로 이동"
    );

    return;
  }
}

두 조건을 모두 통과한 이후에는 가능한 값이 남아 있지 않습니다.

개념적으로 해당 지점의 `direction`은 `never`가 될 수 있습니다.

처음
"left" | "right"

left 처리 후
"right"

right 처리 후
남은 값 없음
→ never

이 특성을 이용하면 모든 경우를 처리했는지 검사할 수 있습니다.

45. 완전성 검사와 `never`

상태 타입을 만들어 보겠습니다.

type Status =
  | "idle"
  | "loading"
  | "success";

각 상태의 메시지를 반환하는 함수를 작성합니다.

function getStatusMessage(
  status: Status
): string {
  switch (status) {
    case "idle":
      return "대기 중";

    case "loading":
      return "불러오는 중";

    case "success":
      return "성공";

    default: {
      const exhaustiveCheck:
        never = status;

      return exhaustiveCheck;
    }
  }
}

모든 상태를 처리했으므로 `default`에 도달할 수 있는 값이 없습니다.

따라서 `status`를 `never` 변수에 할당할 수 있습니다.

이제 새로운 상태를 추가해 보겠습니다.

type Status =
  | "idle"
  | "loading"
  | "success"
  | "error";

하지만 `switch`문에는 `"error"` 처리가 없습니다.

그러면 `default`에서 `status`는 `"error"`일 수 있으므로 `never`에 할당할 수 없습니다.

TypeScript가 빠진 상태 처리를 알려줍니다.

Type 'string' is not assignable
to type 'never'.

이것을 완전성 검사라고 합니다.

46. `assertNever()` 함수 만들기

완전성 검사를 함수로 분리할 수도 있습니다.

function assertNever(
  value: never
): never {
  throw new Error(
    `처리되지 않은 값: ${value}`
  );
}

상태 타입을 만듭니다.

type RequestStatus =
  | "idle"
  | "loading"
  | "success"
  | "error";

함수에서 모든 상태를 처리합니다.

function getStatusMessage(
  status: RequestStatus
): string {
  switch (status) {
    case "idle":
      return "대기 중입니다.";

    case "loading":
      return "불러오는 중입니다.";

    case "success":
      return "완료되었습니다.";

    case "error":
      return "오류가 발생했습니다.";

    default:
      return assertNever(status);
  }
}

새로운 상태가 추가되었는데 `switch` 처리를 빼먹으면 `assertNever(status)`에서 오류를 발견할 수 있습니다.

type RequestStatus =
  | "idle"
  | "loading"
  | "success"
  | "error"
  | "cancelled";

`"cancelled"` 처리를 추가하지 않았다면 TypeScript가 경고합니다.

상태 목록이 늘어날 때 빠진 분기를 찾아주는 출석 검사 장치입니다. 📋

47. `never`를 일반 변수에 직접 사용할 일은 드물다

다음 선언은 가능하지만 실제 값은 넣을 수 없습니다.

let value: never;
value = "TypeScript";
value = 100;
value = false;

모두 오류입니다.

따라서 `never`는 일반 데이터 저장용 타입이 아닙니다.

주로 다음 상황에서 등장합니다.

항상 오류를 던지는 함수
끝나지 않는 함수
모든 경우가 제거된 타입
완전성 검사
도달할 수 없는 코드 표현

`never`는 데이터를 담는 상자가 아니라 “이곳에는 상자가 존재할 수 없음”이라는 표지판에 가깝습니다.

48. 네 타입을 한눈에 비교하기

타입값을 받을 수 있는가?바로 사용할 수 있는가?대표 용도
`any`거의 모두 가능대부분 가능임시 변환, 타입 검사 우회
`unknown`모두 가능타입 확인 필요외부 데이터, 사용자 입력
`void`주로 함수 반환에 사용반환값 사용 안 함로그, 이벤트, 상태 변경
`never`실제 값 없음해당 없음오류 함수, 무한 반복, 완전성 검사

간단한 이미지로 기억해 보겠습니다.

any
문이 열려 있고 경비원도 없음

unknown
문은 열려 있지만 보안 검색 필수

void
직원이 업무 후 빈손으로 돌아옴

never
직원이 출발했지만 정상적으로 돌아오지 않음

49. `any`를 선택해야 하는 경우

다음 조건에 해당하면 제한적으로 `any`를 고려할 수 있습니다.

  • JavaScript 프로젝트를 점진적으로 TypeScript로 변환하는 중
  • 타입 정의가 전혀 없는 오래된 라이브러리와 연동
  • 임시 테스트 또는 디버깅 단계
  • 타입을 지금 당장 표현할 수 없지만 이후 개선 계획이 있음
function migrateLegacyData(
  data: any
) {
  // 임시 호환 코드
}

가능하다면 다음 작업을 함께 남겨두는 것이 좋습니다.

// TODO:
// 실제 데이터 구조 확인 후
// LegacyData 타입으로 교체

`any`는 빚과 비슷합니다.

급할 때 도움은 되지만 갚을 계획 없이 쌓아두면 이자가 런타임 오류로 돌아옵니다.

50. `unknown`을 선택해야 하는 경우

다음 상황에서는 `unknown`을 우선 고려할 수 있습니다.

  • 외부 API 응답 구조가 확실하지 않음
  • 사용자가 입력한 JSON
  • 로컬 스토리지에서 읽은 값
  • 메시지 큐나 이벤트에서 받은 데이터
  • `catch`에서 받은 오류
  • 플러그인 또는 확장 모듈에서 전달된 값
  • 어떤 값이든 받을 수 있는 범용 함수
function validateInput(
  input: unknown
): boolean {
  if (
    typeof input === "string"
  ) {
    return (
      input.trim().length > 0
    );
  }

  return false;
}

핵심 기준:

타입은 모르지만
사용 전 검증할 의향이 있다
→ unknown

51. `void`를 선택해야 하는 경우

함수가 결과값보다 동작 자체를 목적으로 한다면 `void`가 적합합니다.

function printLog(
  message: string
): void {
  console.log(message);
}
function updateUserInterface():
  void {
  console.log(
    "화면을 갱신합니다."
  );
}
function handleClick():
  void {
  console.log(
    "클릭 처리"
  );
}

대표 상황:

  • 로그 출력
  • 화면 갱신
  • 이벤트 처리
  • 상태 변경
  • 파일 저장 요청
  • 알림 표시
  • 반환값을 사용하지 않는 콜백

52. `never`를 선택해야 하는 경우

함수가 정상적으로 호출 지점에 돌아오지 않는다면 `never`를 사용할 수 있습니다.

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

또는 가능한 모든 상태를 처리했는지 확인할 때 사용합니다.

function assertNever(
  value: never
): never {
  throw new Error(
    `처리되지 않은 값: ${value}`
  );
}

대표 상황:

  • 항상 오류를 던지는 함수
  • 영원히 끝나지 않는 반복 함수
  • 도달할 수 없는 분기
  • 리터럴 유니언의 완전성 검사

53. 실습 1: 외부 입력 안전하게 변환하기

어떤 타입의 값이 들어올지 모르는 입력을 숫자로 변환해 보겠습니다.

function toNumber(
  value: unknown
): number | null {
  if (
    typeof value === "number"
  ) {
    if (
      Number.isNaN(value)
    ) {
      return null;
    }

    return value;
  }

  if (
    typeof value === "string"
  ) {
    const convertedValue =
      Number(value);

    if (
      Number.isNaN(
        convertedValue
      )
    ) {
      return null;
    }

    return convertedValue;
  }

  return null;
}

함수를 호출합니다.

console.log(
  toNumber(100)
);

console.log(
  toNumber("200")
);

console.log(
  toNumber("TypeScript")
);

console.log(
  toNumber(true)
);

출력 결과:

100
200
null
null

입력은 `unknown`으로 넓게 받지만, 결과는 `number | null`로 명확하게 제한했습니다.

54. 실습 2: API 응답 검사하기

API 사용자 타입을 정의합니다.

type ApiUser = {
  id: number;
  name: string;
  email: string;
};

검사 함수를 만듭니다.

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

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

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

응답 처리 함수를 작성합니다.

function handleUserResponse(
  response: unknown
): void {
  if (
    !isApiUser(response)
  ) {
    console.log(
      "잘못된 사용자 응답입니다."
    );

    return;
  }

  console.log(
    `사용자: ${response.name}`
  );

  console.log(
    `이메일: ${response.email}`
  );
}

정상 데이터를 전달합니다.

handleUserResponse({
  id: 1,
  name: "김타입",
  email: "type@example.com",
});

잘못된 데이터를 전달합니다.

handleUserResponse({
  id: "1",
  name: "김타입",
});

출력 결과:

사용자: 김타입
이메일: type@example.com

잘못된 사용자 응답입니다.

55. 실습 3: 안전한 오류 메시지 만들기

어떤 오류가 들어와도 문자열 메시지로 변환하는 함수를 작성합니다.

function getErrorMessage(
  error: unknown
): string {
  if (
    error instanceof Error
  ) {
    return error.message;
  }

  if (
    typeof error === "string"
  ) {
    return error;
  }

  if (
    typeof error === "object" &&
    error !== null &&
    "message" in error &&
    typeof error.message === "string"
  ) {
    return error.message;
  }

  return "알 수 없는 오류가 발생했습니다.";
}

사용합니다.

try {
  throw new Error(
    "파일을 찾을 수 없습니다."
  );
} catch (error: unknown) {
  console.log(
    getErrorMessage(error)
  );
}

문자열 오류도 처리할 수 있습니다.

try {
  throw "네트워크 연결 실패";
} catch (error: unknown) {
  console.log(
    getErrorMessage(error)
  );
}

56. 실습 4: 작업 상태 완전성 검사하기

상태 타입을 정의합니다.

type TaskStatus =
  | "waiting"
  | "running"
  | "completed"
  | "failed";

처리되지 않은 상태를 막는 함수를 만듭니다.

function assertNever(
  value: never
): never {
  throw new Error(
    `알 수 없는 상태: ${value}`
  );
}

상태 메시지를 반환합니다.

function getTaskMessage(
  status: TaskStatus
): string {
  switch (status) {
    case "waiting":
      return "작업 대기 중";

    case "running":
      return "작업 실행 중";

    case "completed":
      return "작업 완료";

    case "failed":
      return "작업 실패";

    default:
      return assertNever(status);
  }
}

호출합니다.

console.log(
  getTaskMessage("running")
);

출력 결과:

작업 실행 중

새로운 상태를 추가하면 빠진 처리를 TypeScript가 발견할 수 있습니다.

type TaskStatus =
  | "waiting"
  | "running"
  | "completed"
  | "failed"
  | "cancelled";

`"cancelled"` 분기를 추가하지 않으면 완전성 검사에서 오류가 발생합니다.

57. 실습 5: 이벤트 처리 함수 만들기

이벤트 타입을 정의합니다.

type AppEvent =
  | "login"
  | "logout"
  | "refresh";

이벤트 처리 함수는 값을 반환하지 않습니다.

function handleEvent(
  event: AppEvent
): void {
  switch (event) {
    case "login":
      console.log(
        "로그인 이벤트 처리"
      );
      return;

    case "logout":
      console.log(
        "로그아웃 이벤트 처리"
      );
      return;

    case "refresh":
      console.log(
        "새로고침 이벤트 처리"
      );
      return;

    default:
      assertNever(event);
  }
}

호출합니다.

handleEvent("login");
handleEvent("refresh");

여기에서는 두 타입이 함께 사용되었습니다.

void
이벤트 처리 함수의 반환값을 사용하지 않음

never
처리되지 않은 이벤트가 남아 있지 않은지 검사

58. 자주 발생하는 실수

실수 1. 모르겠으면 무조건 `any` 사용하기

function handleData(
  data: any
) {
  console.log(
    data.user.profile.name
  );
}

데이터 구조가 다르면 실행 중 오류가 발생합니다.

외부 데이터에는 `unknown`을 고려합니다.

function handleData(
  data: unknown
) {
  // 구조 검사 후 사용
}

실수 2. `unknown` 값을 확인하지 않고 사용하기

function printValue(
  value: unknown
) {
  console.log(
    value.toUpperCase()
  );
}

타입을 확인합니다.

function printValue(
  value: unknown
) {
  if (
    typeof value === "string"
  ) {
    console.log(
      value.toUpperCase()
    );
  }
}

실수 3. 객체 확인 시 `null` 제외하지 않기

if (
  typeof value === "object"
) {
  console.log(
    "객체입니다."
  );
}

`null`도 통과할 수 있습니다.

if (
  typeof value === "object" &&
  value !== null
) {
  console.log(
    "null이 아닌 객체입니다."
  );
}

실수 4. JSON 타입을 작성하면 검증도 된다고 생각하기

const user: User =
  JSON.parse(jsonText);

타입 작성만으로 실제 JSON 구조가 검증되지는 않습니다.

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

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

실수 5. `void`를 아무것도 실행하지 않는 함수로 이해하기

function saveData(): void {
  console.log(
    "데이터를 저장합니다."
  );
}

`void` 함수도 여러 작업을 수행할 수 있습니다.

의미 있는 반환값을 제공하지 않을 뿐입니다.

실수 6. `void`와 `never`를 같은 것으로 생각하기

function printLog(): void {
  console.log("로그");
}

함수는 정상적으로 종료됩니다.

function fail(): never {
  throw new Error("실패");
}

함수는 정상적으로 돌아오지 않습니다.

실수 7. 모든 오류가 `Error` 객체라고 가정하기

catch (error) {
  console.log(
    error.message
  );
}

문자열이나 다른 값이 던져질 수도 있습니다.

catch (error: unknown) {
  if (
    error instanceof Error
  ) {
    console.log(
      error.message
    );
  }
}

실수 8. `never` 함수에서 정상적으로 반환하기

function stop(): never {
  return;
}

`never` 함수는 정상적인 종료 지점에 도달하면 안 됩니다.

function stop(): never {
  throw new Error(
    "실행 중단"
  );
}

실수 9. `any`를 반환하는 함수를 그대로 퍼뜨리기

function getData(): any {
  return externalData;
}

const data = getData();

이후 코드까지 `any`의 영향을 받을 수 있습니다.

가능하다면 경계에서 검증하고 명확한 타입을 반환합니다.

function getUser():
  User | null {
  const data: unknown =
    externalData;

  if (
    isUser(data)
  ) {
    return data;
  }

  return null;
}

59. 네 타입 선택 공식

타입을 선택하기 어려울 때 다음 질문을 순서대로 확인해 보세요.

질문 1. 값의 타입을 정확히 알고 있는가?

구체적인 타입을 사용합니다.

string
number
User
Product[]

`any`나 `unknown`이 필요하지 않습니다.

질문 2. 타입은 모르지만 사용 전에 검사할 수 있는가?

`unknown`을 사용합니다.

function handleInput(
  input: unknown
) {
  // 타입 검사
}

질문 3. 기존 코드 호환 때문에 타입 검사를 잠시 건너뛰어야 하는가?

범위를 최소화하여 `any`를 고려합니다.

const legacyValue: any =
  legacyLibrary.getValue();

가능한 빨리 구체적인 타입이나 `unknown`으로 교체합니다.

질문 4. 함수가 작업만 수행하고 반환값을 제공하지 않는가?

`void`를 사용합니다.

function printLog(): void {
  console.log("로그");
}

질문 5. 함수가 항상 오류를 던지거나 절대로 종료되지 않는가?

`never`를 사용합니다.

function fail(): never {
  throw new Error("실패");
}

질문 6. 모든 상태를 처리했는지 검사해야 하는가?

`never`를 이용한 완전성 검사를 고려합니다.

function assertNever(
  value: never
): never {
  throw new Error(
    `처리되지 않은 값: ${value}`
  );
}

60. 미니 퀴즈

문제 1

다음 코드에서 TypeScript가 존재하지 않는 메서드를 막지 못할 수 있는 이유는 무엇일까요?

let value: any =
  "TypeScript";

value.fly();

정답

`value`가 `any` 타입이기 때문입니다.

`any`는 대부분의 타입 검사를 건너뜁니다.

문제 2

다음 코드에서 오류가 발생하는 이유는 무엇일까요?

let value: unknown =
  "TypeScript";

value.toUpperCase();

정답

`value`의 실제 타입을 확인하지 않았기 때문입니다.

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

문제 3

`any`와 `unknown`의 가장 큰 차이는 무엇일까요?

정답

둘 다 어떤 값이든 받을 수 있지만, `unknown`은 타입을 확인하기 전에는 대부분의 작업을 허용하지 않습니다.

문제 4

다음 함수의 반환 타입으로 알맞은 것은 무엇일까요?

function printMessage() {
  console.log(
    "안녕하세요."
  );
}

정답

void

함수는 메시지를 출력하지만 의미 있는 값을 반환하지 않습니다.

문제 5

다음 함수의 반환 타입은 무엇일까요?

function fail() {
  throw new Error(
    "실패했습니다."
  );
}

정답

명시적으로 표현한다면 `never`를 사용할 수 있습니다.

function fail(): never {
  throw new Error(
    "실패했습니다."
  );
}

문제 6

다음 두 함수는 어떤 차이가 있을까요?

function first(): void {
  console.log("완료");
}
function second(): never {
  throw new Error("중단");
}

정답

`first()`는 정상적으로 종료되지만 사용할 반환값이 없습니다.

`second()`는 오류를 던지므로 정상적으로 호출한 곳으로 돌아오지 않습니다.

문제 7

다음 객체 검사의 문제는 무엇일까요?

if (
  typeof value === "object"
) {
  console.log(
    "객체입니다."
  );
}

정답

`null`도 `typeof` 결과가 `"object"`이므로 조건을 통과할 수 있습니다.

if (
  typeof value === "object" &&
  value !== null
) {
  console.log(
    "null이 아닌 객체입니다."
  );
}

문제 8

외부 API 응답에 가장 안전한 시작 타입은 무엇일까요?

const response: ______ =
  getExternalData();

정답 예시

const response: unknown =
  getExternalData();

구조를 검사한 뒤 구체적인 타입으로 사용합니다.

문제 9

다음 함수에서 `never`를 사용하는 목적은 무엇일까요?

function assertNever(
  value: never
): never {
  throw new Error(
    String(value)
  );
}

정답

유니언 타입의 모든 경우가 처리되었는지 확인하고, 도달해서는 안 되는 분기를 표현하기 위해 사용합니다.

문제 10

다음 코드에서 빈칸에 알맞은 타입을 작성해 보세요.

function handleClick():
  ______ {
  console.log(
    "클릭 처리"
  );
}

정답

function handleClick():
  void {
  console.log(
    "클릭 처리"
  );
}

61. 핵심 정리

`any`

어떤 값이든 저장하고 대부분의 작업을 허용합니다.

let value: any;

value = "TypeScript";
value = 100;

value.notExistingMethod();

타입 검사를 약화하므로 사용 범위를 최소화해야 합니다.

`unknown`

어떤 값이든 저장할 수 있지만 실제 타입을 확인한 뒤 사용해야 합니다.

let value: unknown =
  "TypeScript";

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

외부 데이터나 사용자 입력에 적합합니다.

`any`와 `unknown`

any
입장 가능
검사 없음
바로 사용 가능

unknown
입장 가능
검사 필수
확인 후 사용 가능

`void`

함수가 의미 있는 반환값을 제공하지 않음을 나타냅니다.

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

함수는 정상적으로 종료됩니다.

`never`

절대로 발생하지 않는 값이나 정상적으로 종료되지 않는 함수를 나타냅니다.

function fail(): never {
  throw new Error(
    "오류가 발생했습니다."
  );
}

`void`와 `never`

void
정상적으로 돌아오지만
사용할 반환값이 없음

never
정상적인 경로로
돌아오지 않음

완전성 검사

function assertNever(
  value: never
): never {
  throw new Error(
    `처리되지 않은 값: ${value}`
  );
}

리터럴 유니언의 모든 경우를 처리했는지 확인할 수 있습니다.

62. 마무리

TypeScript의 타입은 값을 분류하는 이름표만이 아닙니다.

코드를 얼마나 엄격하게 검사할지 결정하는 안전 정책이기도 합니다.

`any`는 검사 정책을 거의 해제합니다.

let data: any;

무엇이든 받을 수 있고, 대부분의 작업을 바로 시도할 수 있습니다.

편리하지만 TypeScript가 제공하는 보호 장치를 스스로 내려놓게 됩니다.

`unknown`은 무엇이든 받을 수 있지만 확인 전에는 사용할 수 없습니다.

let data: unknown;
if (
  typeof data === "string"
) {
  console.log(
    data.toUpperCase()
  );
}

외부에서 들어오는 값처럼 실제 타입을 확신할 수 없는 상황에 잘 어울립니다.

`void`는 함수가 작업을 수행하지만 호출자가 사용할 결과를 제공하지 않는다는 뜻입니다.

function updateScreen(): void {
  console.log(
    "화면을 갱신합니다."
  );
}

`never`는 정상적으로 돌아오는 일이 없다는 뜻입니다.

function stopProgram(): never {
  throw new Error(
    "프로그램을 중단합니다."
  );
}

네 타입을 한 문장씩 정리하면 다음과 같습니다.

any
“무엇이든 좋으니 검사하지 마세요.”

unknown
“무엇이든 받을 수 있지만 먼저 확인하세요.”

void
“작업은 마쳤지만 전달할 결과는 없습니다.”

never
“이 경로에서는 정상적인 귀환이 없습니다.”

가장 중요한 실무 원칙은 다음과 같습니다.

타입을 모른다면 `any`로 포기하기 전에 `unknown`으로 확인할 방법부터 찾자.

TypeScript는 모든 상황에서 완벽한 타입을 요구하지 않습니다.

다만 모르는 것을 아는 척하지 말고, 모른다는 사실도 타입으로 정직하게 표현하라고 말합니다.

`unknown`은 무지가 아니라 확인 절차가 포함된 정직함입니다.

그리고 그 한 번의 확인이 새벽의 런타임 오류를 낮 시간의 컴파일 오류로 바꿔줍니다. 🔦

63. 다음 편 예고

지금까지 우리는 타입 검사를 거의 건너뛰는 `any`와, 사용 전 확인을 요구하는 `unknown`을 살펴봤습니다.

let unsafeValue: any;
let safeValue: unknown;

`void`와 `never`를 통해 함수가 어떻게 끝나는지도 타입으로 표현했습니다.

function printLog(): void {
  console.log("로그");
}

function fail(): never {
  throw new Error("실패");
}

그런데 개발자가 TypeScript보다 값의 타입을 더 정확히 알고 있는 순간도 있습니다.

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

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

TypeScript는 `input`을 일반적인 `Element | null`로 판단할 수 있습니다.

하지만 개발자는 해당 요소가 분명히 `<input>`이라는 사실을 알고 있을 수 있습니다.

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

`as HTMLInputElement`는 TypeScript에게 다음과 같이 말합니다.

“이 값의 실제 타입은 내가 더 정확히 알고 있습니다.”

이것을 타입 단언(Type Assertion)이라고 합니다.

그런데 타입 단언을 사용하면 실제 값도 자동으로 변할까요?

const value =
  "100" as unknown as number;

문자열 `"100"`이 정말 숫자 `100`으로 변환될까요?

정답은 아닙니다.

타입 단언은 값을 변환하지 않고 TypeScript의 판단만 바꿉니다.

잘못 사용하면 타입 오류를 숨길 수도 있습니다.

const user =
  {} as User;

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

또한 값이 절대로 `null`이나 `undefined`가 아니라고 주장하는 연산자도 있습니다.

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

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

`!`는 정말 값을 안전하게 만들어 주는 것일까요?

다음 편에서는 TypeScript에게 개발자의 판단을 전달하는 방법과, 그 판단이 틀렸을 때 어떤 문제가 발생하는지 살펴보겠습니다.

다음 이야기

[TypeScript 완전정복 #11] TypeScript에게 “내가 더 잘 알아”라고 말하는 방법 | 타입 단언과 안전한 타입 처리

  • 타입 단언은 무엇일까요?
  • `as` 문법은 어떻게 사용할까요?
  • 꺾쇠괄호 방식과 `as` 방식은 무엇이 다를까요?
  • 타입 단언은 실제 값을 변환할까요?
  • 잘못된 타입 단언은 왜 위험할까요?
  • `unknown`을 확인한 뒤 단언하려면 어떻게 해야 할까요?
  • `!` 비어 있지 않음 단언 연산자는 무엇일까요?
  • DOM 요소를 안전하게 다루려면 어떻게 해야 할까요?
  • `as const`는 일반 타입 단언과 무엇이 다를까요?
  • 타입 단언을 사용하지 않고 문제를 해결하는 방법은 무엇일까요?

다음 편에서는 TypeScript의 판단을 수정할 수 있는 타입 단언을 살펴보고, 확신과 억지 주장 사이의 위험한 경계선을 함께 걸어보겠습니다.

댓글

0

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