TypeScript의 any, unknown, never: 어느 것을 써야 할까?

1. 개요

TypeScript에는 세 가지 특별한 타입이 있어 혼동하기 쉽습니다: any, unknown, never. 이 세 가지 모두 타입 시스템의 “경계”에 나타납니다. any는 타입 검사를 해제하고, unknown은 사용하기 전에 검사를 강제하며, never는 불가능한 경우를 나타냅니다.

이 글을 다 읽으면, 출처가 불명확한 데이터에 어떤 타입을 선택해야 할지 알 수 있습니다. 또한 never를 사용해 처리하지 않은 분기가 있을 때 컴파일러가 오류를 보내도록 하는 방법도 배우게 됩니다.

모든 예제는 TypeScript 5.x와 tsconfig.json의 "strict": true에서 실행됩니다.

2. any: 타입 검사 해제

any는 값이 무엇이든 될 수 있게 합니다. 컴파일러는 아무것도 검사하지 않습니다:

let payload: any = "안녕하세요";

payload.toUpperCase();   // OK
payload.foo.bar();       // 컴파일 시 OK, 런타임에 오류
payload = 42;
const total: number = payload * 2; // OK

세 번째 줄은 정상적으로 컴파일됩니다. 실행할 때 오류가 발생합니다:

TypeError: Cannot read properties of undefined (reading 'bar')

any를 사용하면 TypeScript는 당신이 책임을 진다고 가정합니다. 자동 완성, 속성 이름 검사, 그리고 안전한 리팩토링의 이점을 모두 잃게 됩니다.

2.1. any의 확산

any의 가장 큰 문제는 이것이 닿는 모든 것에 퍼진다는 것입니다:

function loadConfig(): any {
  return JSON.parse('{"port": "8080"}');
}

const config = loadConfig();
const port = config.port;      // port: any
const next = port + 1;         // next: any
console.log(next);
80801

port는 문자열 "8080"이므로 port + 1은 8081 대신 "80801"을 반환합니다. 컴파일러는 경고하지 않습니다. any가 config, port, next를 통해 전파되었기 때문입니다. 시작 부분의 한 개의 any가 함수 호출 체인 전체의 타입 검사를 무효화할 수 있습니다.

참고: JSON.parse, fetch API의 response.json(), 그리고 많은 레거시 라이브러리는 any를 반환합니다. 이것이 실제 코드에서 any 누수의 가장 일반적인 원인입니다.

3. unknown: 타입을 알 수 없을 때 안전함

unknown도 any처럼 모든 값을 받을 수 있습니다. 다른 점은 타입을 좁혀(narrowing)서 증명할 때까지 해당 값을 사용할 수 없다는 것입니다:

let payload: unknown = "안녕하세요";

payload.toUpperCase();
error TS18046: 'payload' is of type 'unknown'.

컴파일러가 즉시 차단합니다. 먼저 payload가 문자열임을 증명해야 합니다:

if (typeof payload === "string") {
  console.log(payload.toUpperCase()); // payload: string
}
안녕하세요

if 블록 내에서 TypeScript는 payload가 string임을 알므로 toUpperCase()가 유효합니다.

3.1. unknown 좁히는 방법

주요 도구는 네 가지입니다:

class PaymentError extends Error {
  constructor(message: string, public readonly code: string) {
    super(message);
  }
}

function describe(value: unknown): string {
  if (typeof value === "number") {
    return `숫자: ${value.toFixed(2)}`;
  }
  if (value instanceof PaymentError) {
    return `결제 오류 ${value.code}`;
  }
  if (typeof value === "object" && value !== null && "id" in value) {
    return `id를 가진 객체: ${String(value.id)}`;
  }
  return "알 수 없음";
}

console.log(describe(12.5));
console.log(describe(new PaymentError("돈 부족", "INSUFFICIENT")));
console.log(describe({ id: 7 }));
console.log(describe(null));
숫자: 12.50
결제 오류 INSUFFICIENT
id를 가진 객체: 7
알 수 없음
  • typeof는 원시 타입용: string, number, boolean 등
  • instanceof는 클래스용
  • in은 속성 존재 여부 확인. TypeScript 4.9부터 "id" in value 후 value는 object & Record<"id", unknown> 타입을 가집니다.
  • value !== null을 반드시 확인해야 합니다. typeof null === "object"이기 때문입니다.

3.2. 외부 데이터용 type guard

복잡한 객체의 경우 value is T를 반환하는 type guard 함수를 작성하는 것이 좋습니다:

interface Order {
  id: string;
  customerName: string;
  total: number;
}

export function isOrder(value: unknown): value is Order {
  if (typeof value !== "object" || value === null) return false;
  const v = value as Record<string, unknown>;
  return (
    typeof v.id === "string" &&
    typeof v.customerName === "string" &&
    typeof v.total === "number"
  );
}

export function parseOrder(json: string): Order {
  const data: unknown = JSON.parse(json);
  if (!isOrder(data)) {
    throw new Error("주문 데이터가 유효하지 않습니다");
  }
  return data; // data: Order
}

console.log(parseOrder('{"id":"A1","customerName":"Lan","total":250000}'));
{ id: 'A1', customerName: 'Lan', total: 250000 }

중요한 점은 const data: unknown = JSON.parse(json) 줄입니다. 명시적으로 unknown 타입을 할당함으로써 JSON.parse의 any가 외부로 퍼지는 것을 차단합니다. 이제부터 컴파일러는 data를 사용하기 전에 검사를 강제합니다.

대규모 프로젝트에서는 수동으로 작성한 type guard를 Zod 같은 스키마 검증 라이브러리로 대체할 수 있습니다. 원칙은 동일합니다: 입력 데이터는 unknown이고, 검증 후에 구체적인 타입이 됩니다.

3.3. catch에서 unknown

strict: true일 때 (특히 useUnknownInCatchVariables 플래그), catch 블록의 변수는 unknown 타입입니다:

try {
  parseOrder("{}");
} catch (err) {
  // err: unknown
  const message = err instanceof Error ? err.message : String(err);
  console.log(message);
}
주문 데이터가 유효하지 않습니다

이것이 현실을 반영합니다. JavaScript에서는 무엇이든 throw할 수 있고, 심지어 문자열이나 숫자도 가능하므로 err이 항상 Error라고 가정할 수 없습니다.

4. never: 값이 없는 타입

never는 빈 타입입니다. never에 속하는 값은 없습니다. 이것은 세 가지 주요 상황에서 나타납니다.

4.1. 절대 반환하지 않는 함수

항상 오류를 throw하거나 무한 루프를 실행하는 함수의 반환 타입은 never입니다:

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

function getDiscount(code: string): number {
  if (code === "TET2026") return 0.2;
  return fail(`할인 코드가 없습니다: ${code}`);
}

never는 모든 타입에 할당될 수 있으므로 return fail(...)은 number를 반환하는 함수에서 유효합니다. 컴파일러는 또한 fail() 호출 이후의 코드는 도달 불가능하다는 것을 이해합니다.

4.2. 완전성 검사

이것이 never의 가장 유용한 사용법입니다. 결제 방법의 union이 있다고 가정하겠습니다:

type PaymentMethod =
  | { kind: "card"; last4: string }
  | { kind: "cash" }
  | { kind: "momo"; phone: string };

export function assertNever(value: never): never {
  throw new Error(`처리되지 않은 경우: ${JSON.stringify(value)}`);
}

export function paymentLabel(method: PaymentMethod): string {
  switch (method.kind) {
    case "card":
      return `카드 **** ${method.last4}`;
    case "cash":
      return "현금";
    case "momo":
      return `MoMo ${method.phone}`;
    default:
      return assertNever(method);
  }
}

세 가지 case를 모두 처리한 후 default 블록에서 method는 never로 좁혀집니다. assertNever(method) 호출이 유효한 이유입니다.

이제 동료가 { kind: "zalopay"; phone: string }을 union에 추가했지만 switch를 수정하지 않았다고 가정하겠습니다. 컴파일러가 즉시 오류를 냅니다:

error TS2345: Argument of type '{ kind: "zalopay"; phone: string; }' is not assignable to parameter of type 'never'.

오류는 컴파일 시간에 감지되며, 사용자가 결제 화면에서 정의되지 않은 레이블을 보는 것을 방지합니다. 예를 들어 레거시 API에서 온 데이터처럼 런타임에 여전히 이상한 데이터가 들어올 수 있다면 assertNever가 명확한 오류를 throw합니다.

4.3. Conditional type에서 never

Conditional type에서 never는 union에서 멤버를 제거하는 데 사용됩니다. A | never은 A이기 때문입니다:

type OnlyStrings<T> = T extends string ? T : never;

type Mixed = "vnd" | "usd" | 404 | null;
type Currencies = OnlyStrings<Mixed>; // "vnd" | "usd"

TypeScript는 conditional type을 union의 각 멤버에 분배합니다. 404와 null 멤버는 never가 되고 사라집니다. 내장 유틸리티 타입 Exclude, Extract, NonNullable은 이 메커니즘으로 작동합니다.

5. 빠른 비교

핵심 차이는 할당 가능성(assignability)에 있습니다:

이 타입에 다른 값 할당 이 타입을 다른 곳에 할당 직접 사용 (메서드 호출, 속성 접근)
any 가능 가능 (never 제외) 가능, 검사 없음
unknown 가능 unknown, any만 가능 불가능, 반드시 좁혀야 함
never 불가능 (never 제외) 가능, 모든 타입 불가능, 값 없음

다음과 같이 요약할 수 있습니다:

  • unknown은 top type 안전성. 모든 것이 unknown입니다.
  • never는 bottom type. never는 모든 타입의 부타입입니다.
  • any는 탈출구입니다. top type과 bottom type의 역할을 동시에 하므로 타입 시스템의 정확성을 파괴합니다.

6. 동작 테스트

Vitest를 사용해 런타임 동작과 컴파일 타입을 모두 테스트할 수 있습니다. 설치:

npm install -D vitest typescript

섹션 3.2와 4.2의 함수가 각각 order.ts와 payment.ts에 있다고 가정하겠습니다:

// payment.test.ts
import { describe, it, expect, expectTypeOf } from "vitest";
import { isOrder, parseOrder } from "./order";
import { paymentLabel, assertNever } from "./payment";

describe("unknown과 type guard", () => {
  it("유효한 주문을 수락합니다", () => {
    const order = parseOrder('{"id":"A1","customerName":"Lan","total":1000}');
    expect(order.total).toBe(1000);
    expectTypeOf(order.total).toEqualTypeOf<number>();
  });

  it("잘못된 타입의 데이터를 거부합니다", () => {
    expect(isOrder({ id: "A1", customerName: "Lan", total: "1000" })).toBe(false);
    expect(() => parseOrder("null")).toThrow("주문 데이터가 유효하지 않습니다");
  });
});

describe("never와 완전성 검사", () => {
  it("각 결제 방법마다 올바른 레이블을 반환합니다", () => {
    expect(paymentLabel({ kind: "card", last4: "4242" })).toBe("카드 **** 4242");
    expect(paymentLabel({ kind: "cash" })).toBe("현금");
  });

  it("런타임에 union 외의 데이터를 만나면 오류를 throw합니다", () => {
    const legacy = { kind: "paypal" } as unknown as never;
    expect(() => assertNever(legacy)).toThrow("처리되지 않은 경우");
  });
});
npx vitest run
 ✓ payment.test.ts (4 tests)
 Test Files  1 passed (1)
      Tests  4 passed (4)

expectTypeOf는 컴파일 타임에 타입을 검사합니다. 예를 들어 order.total이 어떤 변화로 any가 되면 type check 실행(vitest --typecheck)에 실패합니다. 마지막 테스트는 레거시 시스템 같은 “이상한” 데이터를 시뮬레이션하여 런타임 안전 장치가 여전히 작동함을 보장합니다.

7. 흔한 실수와 피해야 할 때

컴파일 오류를 “통과”하려고 any 사용. 이것이 가장 나쁜 습관입니다. 타입을 정말 모른다면 unknown을 사용하세요. 이런 경우를 잡기 위해 @typescript-eslint/no-explicit-any 규칙을 활성화하세요.

Narrowing 대신 as로 unknown을 강제 타입 변환. data as Order는 컴파일러를 속일 뿐 런타임 검사는 하지 않습니다. 본질적으로 any만큼 위험합니다.

const data: unknown = JSON.parse('{"id": 1}');
const order = data as Order; // 컴파일 OK
order.customerName.trim();   // 런타임에 TypeError

모든 것에 unknown 사용. unknown은 시스템 경계용입니다: 사용자 입력, API 응답, catch, 역직렬화 데이터. 도메인 내에서는 구체적인 타입을 사용하세요. calculateTotal(items: unknown) 같은 함수는 설계가 좋지 않습니다.

never와 void 혼동. void는 함수가 반환하지만 유용한 값이 없다는 뜻입니다. never는 함수가 절대 반환하지 않는다는 뜻입니다. 일반적인 log() 함수는 never가 아니라 void여야 합니다.

switch에 default 빠뜨리기. assertNever가 없으면 TypeScript는 함수가 명시적 반환 타입을 가지고 noImplicitReturns가 활성화된 경우에만 오류를 냅니다. 그런 경우에도 런타임 안전 장치를 잃게 됩니다.

참고: any는 여전히 정당한 사용 사례가 있습니다. 큰 JavaScript 코드베이스를 점진적으로 마이그레이션할 때나 타입이 정말 동적인 라이브러리용 선언을 작성할 때입니다. 용도를 좁게 제한하고 이유를 설명하는 주석을 남기세요.

8. 결론

이 세 가지 타입은 세 가지 명확한 역할을 합니다:

  • 시스템 경계에서 타입이 불명확한 데이터에는 **unknown**을 사용하고, typeof, instanceof, in 또는 type guard로 좁히세요.
  • 항상 오류를 throw하는 함수, assertNever로 완전성 검사, conditional type에서 union 필터링에는 **never**를 사용하세요.
  • any 사용은 피하세요. 구체적인 이유가 있고 용도를 좁게 제한한 경우를 제외하고는요.

실제 규칙은 매우 간단합니다. any를 입력하려고 하면 먼저 unknown을 시도해보세요. 컴파일러가 오류를 내면 그것이 바로 데이터를 검사해야 하는 곳입니다.

typescripttype-safetyunknownneverany