TypeScript unknown과 any의 차이를 경계 코드로 이해하기

TypeScript unknown과 any의 차이를 경계 코드로 이해하기

외부 API 응답이나 JSON 파싱 결과의 타입을 모를 때 any를 붙이면 컴파일 오류는 사라진다. 하지만 불확실성이 사라진 것이 아니라 이후 코드 전체로 퍼진다. unknown은 모른다는 사실을 유지하고 사용 전에 확인하게 만든다.

한눈에 보기

  • any는 타입 검사를 우회하고 연산 결과까지 any로 전파된다.
  • unknown은 모든 값을 받을 수 있지만 좁히기 전에는 사용할 수 없다.
  • 네트워크, 파일, 사용자 입력 경계에서는 unknown에서 검증을 시작한다.

예시 코드 안내

본문의 코드는 특정 저장소 구현을 복사하지 않고 개념을 설명하기 위해 재구성한 예시다. 이름·경로·수치는 실제 운영 정보와 무관하다.

목차

any가 불확실성을 퍼뜨린다

function printName(input: any) {
  console.log(input.user.profile.name.toUpperCase());
}

어떤 단계가 없어도 컴파일은 통과하고 런타임에서 실패한다. 호출부도 반환값의 타입을 신뢰하기 어렵다.

unknown을 타입 가드로 좁히기

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

function isUser(value: unknown): value is User {
  if (typeof value !== "object" || value === null) return false;
  const row = value as Record<string, unknown>;
  return typeof row.id === "string" && typeof row.name === "string";
}

function parseUser(value: unknown): User {
  if (!isUser(value)) throw new Error("Invalid user");
  return value;
}

불확실성은 parseUser 경계에서 끝나고 내부 코드는 User를 안전하게 사용한다.

검증 라이브러리와 함께 사용하기

필드가 많고 중첩이 깊다면 직접 타입 가드를 유지하기 어렵다. 스키마 검증 라이브러리를 이용해 런타임 검증 결과와 TypeScript 타입을 연결할 수 있다.

타입은 런타임 데이터를 바꾸지 않는다

fetch 결과에 as User를 붙여도 서버 응답이 User 형태가 되는 것은 아니다. 외부 데이터에는 실제 검증이 필요하다.

any가 한 번 들어오면 어디까지 퍼질까

any는 해당 값에 대한 검사만 끄는 것이 아니다. 프로퍼티 접근과 함수 호출 결과도 다시 any가 되어 불확실성이 전파된다.

declare const response: any;

const user = response.data.user;
const upperName = user.profile.name.toUpperCase();
const result = upperName.notExistingMethod();

모든 줄이 컴파일될 수 있다. 어느 지점에서 실제 값이 예상과 달랐는지는 런타임 오류가 난 뒤에야 알게 된다.

unknown은 값을 받는 것까지는 허용하지만 사용하기 전에 확인을 요구한다.

declare const response: unknown;

// response.data; // 컴파일 오류

if (typeof response === "object" && response !== null) {
  // 여기서도 필요한 필드를 더 확인해야 한다.
}

외부 응답을 단계적으로 검증하기

작은 타입은 직접 가드를 만들 수 있다.

type Article = {
  id: string;
  title: string;
  tags: string[];
};

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

function isArticle(value: unknown): value is Article {
  if (typeof value !== "object" || value === null) return false;

  const article = value as Record<string, unknown>;
  return (
    typeof article.id === "string" &&
    typeof article.title === "string" &&
    isStringArray(article.tags)
  );
}

여기서 as Record<string, unknown>은 이미 객체임을 확인한 뒤 프로퍼티를 검사하기 위한 좁은 단언이다. 외부 값을 곧바로 as Article로 믿는 것과 범위가 다르다.

async function fetchArticle(id: string): Promise<Article> {
  const response = await fetch(`/api/articles/${id}`);
  const body: unknown = await response.json();

  if (!isArticle(body)) {
    throw new Error("Article response did not match the expected schema");
  }

  return body;
}

이 함수의 밖에서는 Article을 믿어도 된다. 불확실성을 HTTP 경계에서 끝냈기 때문이다.

검증과 변환을 함께 하는 parser

실제 입력은 타입만 틀리는 것이 아니라 표현 방식이 다를 수 있다. 이 경우 boolean을 반환하는 guard보다 성공 값이나 구체적인 오류를 반환하는 parser가 낫다.

type ParseResult<T> =
  | { ok: true; value: T }
  | { ok: false; issues: string[] };

function parsePage(value: unknown): ParseResult<number> {
  const parsed = typeof value === "string" ? Number(value) : value;

  if (!Number.isInteger(parsed) || parsed < 1) {
    return { ok: false, issues: ["page must be a positive integer"] };
  }

  return { ok: true, value: parsed };
}

사용자 입력에는 어떤 필드가 왜 잘못되었는지 알려줘야 하고, 외부 서비스 응답에는 관측 가능한 오류 정보를 남겨야 한다.

catch 변수에도 unknown이 어울린다

JavaScript에서는 무엇이든 throw할 수 있다.

try {
  await runTask();
} catch (error: unknown) {
  if (error instanceof Error) {
    logger.error(error.message, { stack: error.stack });
  } else {
    logger.error("Non-Error value was thrown", { value: String(error) });
  }
}

catch (error: any)로 두고 error.message를 바로 읽으면 문자열이나 null이 throw된 경우 다시 오류가 날 수 있다.

any를 완전히 금지할 수 없는 경계

타입 정의가 없는 레거시 라이브러리와 복잡한 generic 구현 내부에서는 제한적으로 any가 필요할 수 있다. 중요한 것은 범위를 작게 만들고 외부 API에는 구체 타입을 노출하는 것이다.

// 내부 구현의 호환성 처리에만 any를 가둔다.
function callLegacyLibrary(input: unknown): string {
  const legacy = untypedLibrary as any;
  const result: unknown = legacy.execute(input);

  if (typeof result !== "string") {
    throw new Error("Legacy library returned a non-string value");
  }

  return result;
}
선택 의미 권장 위치
unknown 아직 모르는 값 JSON, 사용자 입력, catch
any 타입 검사 우회 격리된 호환 계층
type guard 검사 후 타입 좁힘 작은 런타임 경계
schema parser 검증·변환·오류 수집 공개 API, 큰 객체
type assertion 컴파일러보다 더 아는 정보 DOM, 검증 직후의 좁은 구간

결론

unknown은 불편함을 통해 검증이 필요한 위치를 알려 준다. any는 라이브러리 경계의 제한된 구간에서만 사용하고 가능한 빨리 구체 타입으로 좁힌다.

관련 노트