TypeScript 타입 좁히기: union null unknown 안전 검증

2026.09.05·수정 2026.09.12·약 18분·작성: 해비·블로그 소개

이 글에서 완성할 것

union과 null을 조건문으로 좁히고, 외부 값을 unknown으로 받은 뒤 속성을 하나씩 검사해 안전한 Profile로 바꿉니다. 타입 단언이나 any 없이 정상 데이터와 잘못된 데이터를 모두 실행해 봅니다.

실습 경로: 현재 ZIP전체 코드. 먼저 설명에서 지정한 파일만 읽고, 설정·테스트 파일은 필요한 때 확인하세요.

먼저 읽기: TypeScript 기본 타입: 객체와 함수까지 strict로 익히기

목표·선수 지식: 객체·함수 타입을 바탕으로 unknown을 검증하고 null 및 잘못된 숫자를 안전하게 거절합니다. 본문의 짧은 문법 블록은 서로 독립된 부분 예제입니다. 같은 파일에 모두 합치면 중복 선언이 생길 수 있습니다.

strict 환경과 런타임 입력 준비하기

이 실습은 TypeScript 7.0.2, strict: true, ES2022, CommonJS 환경에서 검증했습니다. 아래 명령으로 새 프로젝트를 만든 뒤 정상 예제를 narrowing.ts로 저장하세요. 다음 내용을 tsconfig.json으로 저장하면 strict 검사와 ES2022·CommonJS 출력, dist 폴더가 함께 설정됩니다. 정상 파일은 npx tsc -p tsconfig.json으로 컴파일하고 node dist/narrowing.js로 실행합니다. tsconfig.json 전체 코드

타입은 컴파일할 때 개발자가 작성한 코드의 관계를 검사하지만, 네트워크 응답과 저장소 데이터가 약속한 모양으로 도착했는지 런타임에 확인해 주지는 않습니다. JSON 파싱 결과나 외부 라이브러리 콜백 값은 경계에서 unknown으로 받아 검증해야 합니다. unknown에는 어떤 값이든 들어올 수 있지만 좁히기 전에는 속성 접근이나 메서드 호출을 허용하지 않으므로, 검사를 빠뜨린 자리를 컴파일러가 알려 줍니다.

mkdir ts-narrowing && cd ts-narrowing
npm init -y
npm install --save-dev typescript@7.0.2
npx tsc --version

전체 tsconfig.json는 아래 완성 코드에서 확인하고 복사하세요. tsconfig.json 전체 코드

union을 공통 리터럴 속성으로 좁히기

union은 값이 여러 타입 중 하나일 수 있음을 세로 막대 |로 표현합니다. 예제의 LookupResult는 조회 성공과 실패 중 하나입니다. 두 객체에 공통으로 있는 status가 서로 다른 문자열 리터럴이므로, 조건문에서 status를 확인하면 TypeScript가 해당 가지의 정확한 객체 타입을 압니다. 성공 가지에만 있는 points나 실패 가지에만 있는 reason을 안전하게 사용할 수 있습니다.

이 방식을 판별 가능한 union이라고 합니다. 비슷한 선택지를 단순히 선택 속성이 많은 객체 하나로 합치면 성공인데 값이 없거나 실패인데 점수가 있는 모순된 상태를 표현할 수 있습니다. 반면 각 상태의 필수 필드를 따로 적으면 만들 수 있는 상태 자체가 업무 규칙과 가까워집니다. 새 상태가 추가됐을 때 분기해야 할 위치도 드러나므로 규모가 커질수록 읽기 쉽습니다.

union을 좁힐 때 사용할 수 있는 단서는 하나만 있는 것이 아닙니다. 원시 값이라면 typeof로 문자열과 숫자를 나눌 수 있고, 서로 다른 객체 속성이 있다면 "reason" in result처럼 in 연산자를 쓸 수도 있습니다. 그러나 애플리케이션 상태처럼 직접 타입을 설계할 수 있다면 status 같은 공통 리터럴을 두는 편이 분기 의도가 가장 명확합니다. 데이터 구조를 바꾸기 어려운 외부 값에는 typeof와 in을 실제 모양에 맞춰 조합하세요.

그릇 두 개와 정육면체 및 원기둥 나무 블록
그릇 두 개와 정육면체 및 원기둥 나무 블록입니다. 실제 동작은 예제 코드에서 확인합니다.

null을 값의 일부로 선언하고 확인하기

email: string | null은 이메일 문자열이 있거나 명시적으로 없다는 뜻입니다. strict null 검사에서는 null을 문자열처럼 바로 사용할 수 없습니다. profile?.email ?? "이메일 없음"은 profile이 null이면 안전하게 undefined를 만들고, 이메일까지 null 또는 undefined라면 대체 문구를 사용합니다. 빈 문자열은 null과 다른 실제 문자열이므로 ??가 빈 문자열을 임의로 대체하지 않는 점도 중요합니다.

조건문을 쓰는 방법도 분명합니다. if (profile !== null) 블록 안에서는 Profile로 좁혀지고 밖에서는 다시 null 가능성을 고려합니다. 참과 거짓만 보는 느슨한 검사는 빈 문자열이나 0까지 함께 제외할 수 있습니다. 어떤 값을 제외하려는지 정확히 드러내려면 null과 직접 비교하세요. 선택적 연결은 짧은 읽기에 편리하지만, 여러 작업을 해야 한다면 명시적 분기가 더 읽기 좋습니다.

unknown을 객체와 속성 순서로 검증하기

런타임 검증은 바깥에서 안쪽으로 진행합니다. 먼저 typeof value === "object"인지 보고, JavaScript에서 null도 object로 분류되므로 value !== null을 함께 확인합니다. 배열을 일반 프로필 객체로 받지 않기 위해 !Array.isArray(value)도 검사합니다. 이 조건을 통과한 값은 문자열 키와 unknown 값을 가진 레코드로 좁혀져 속성을 안전하게 조사할 수 있습니다.

정상 예제의 "name" in value, "points" in value, "email" in value 검사는 필요한 키가 객체에 있는지 런타임에 직접 확인합니다. 이어 각 속성의 값 타입까지 검사하므로, in 연산자만으로 속성의 내용까지 맞다고 가정하지 않습니다.

isProfile은 name이 문자열인지, points가 유한한 숫자인지, email이 문자열 또는 null인지 모두 확인합니다. 반환 타입 value is Profile은 함수가 true를 반환하면 호출한 곳에서도 Profile로 좁히겠다는 약속입니다. 이 약속은 본문 검사가 실제 Profile의 모든 필수 규칙을 확인할 때만 올바릅니다. 일부 속성만 보고 true를 반환하는 깨진 타입 가드는 컴파일러를 속여 런타임 오류를 만들 수 있습니다.

검증 함수 안에는 타입 단언이 없습니다. as Profile은 값을 검사하지 않고 컴파일러에게 믿으라고 요청할 뿐이며, any는 이후 접근의 검사를 사실상 꺼 버립니다. 외부 경계에서는 unknown을 유지하고 각 필드를 실제 JavaScript 조건으로 확인해야 타입 정보와 런타임 사실이 일치합니다. 숫자 검사에는 typeof만으로는 NaN과 Infinity가 포함되므로 점수 규칙에 맞게 Number.isFinite까지 사용했습니다.

검증 결과를 boolean만 반환할지 상세한 오류 정보를 반환할지는 프로그램의 필요에 따라 고를 수 있습니다. 지금 예제는 좁히기 원리에 집중하므로 true와 false로 충분합니다. 실제 폼이나 API에서는 어떤 필드가 잘못됐는지 사용자와 로그에 알려야 할 수 있습니다. 그때도 먼저 unknown을 안전하게 살펴보는 원칙은 같습니다. 실패 이유를 별도 union으로 만들면 검증 실패와 정상 Profile이 섞이지 않고 호출하는 코드가 모든 결과를 분명히 처리할 수 있습니다.

전체 narrowing.ts는 아래 완성 코드에서 확인하고 복사하세요. narrowing.ts 전체 코드

예상 출력

서연: 95점
이메일 없음
조회 실패: 프로필 형식 오류
이메일 없음

첫 값은 모든 필드 검사를 통과하므로 found 가지가 되고, null 이메일은 대체 문구로 출력됩니다. 둘째 값의 points는 문자열이므로 검증에 실패합니다. 이후 코드는 잘못된 객체의 name이나 email을 신뢰하지 않고 missing 결과를 만듭니다. 실패 데이터에서도 프로그램이 중단되지 않고 정해진 메시지를 내는 것이 이 실습의 런타임 완료 기준입니다.

배열에 정상 값과 오류 값을 함께 넣은 이유는 성공 경로만 확인하면 검증기가 실제로 거부하는지 알 수 없기 때문입니다. 검증 코드를 수정할 때는 최소한 통과해야 할 예와 거부해야 할 예를 한 쌍으로 실행하세요. points를 숫자 문자열로 바꾸었을 때 거부되고, 유한한 숫자로 바꾸면 통과하는지 관찰하면 규칙이 선명해집니다. null, 배열, 빈 객체도 rawValues에 추가하면 바깥쪽 검사 순서가 런타임 예외를 막는 모습까지 확인할 수 있습니다.

포장된 상자와 내용을 확인하는 돋보기
포장된 상자와 내용을 확인하는 돋보기입니다. 실제 동작은 예제 코드에서 확인합니다.

좁히기 전 접근이 막히는 오류 읽기

아래 파일은 의도적인 컴파일 실패 예제입니다. 정상 파일과 오류 파일을 섞지 않고 검사하도록 npx tsc -p tsconfig.error.json를 사용합니다. 첫 함수는 unknown의 length에 바로 접근해 TS18046이 발생합니다. 둘째 함수는 null일 수 있는 name에 문자열 메서드를 호출해 TS18047이 발생합니다. tsconfig.error.json 전체 코드

전체 compile-error.ts는 아래 완성 코드에서 확인하고 복사하세요. compile-error.ts 전체 코드

오류를 없애려고 강제 단언을 붙이지 마세요. 첫 함수는 typeof value === "string"이나 Array.isArray(value)처럼 허용할 실제 종류를 검사해야 합니다. 둘째 함수는 null일 때 반환할 문구를 정하거나, null이 아닌 분기에서만 대문자로 바꿔야 합니다. 컴파일 오류는 빠진 런타임 규칙을 발견하는 질문으로 읽는 편이 좋습니다.

좁히기는 검사가 유효한 코드 범위에서만 유지됩니다. 조건문 안에서 문자열로 확인한 값을 나중에 다른 값으로 다시 대입하면 TypeScript는 새 타입을 기준으로 판단합니다. 객체 속성이 비동기 작업 중 바뀔 수 있는 구조라면 검사한 값을 지역 상수에 담아 사용하는 방법도 고려할 수 있습니다. 핵심은 예전에 한 검사를 영구 보증으로 생각하지 않고, 실제 사용 시점에 컴파일러가 알고 있는 타입을 확인하는 것입니다. 편집기의 타입 표시와 터미널 오류를 함께 보면 범위를 더 빠르게 익힐 수 있습니다.

직접 확인하는 경계와 재현 방법

검증 정책은 이메일 형식이나 점수 범위가 아니라 필드의 타입과 유한성입니다. 따라서 빈 이름과 음수 점수도 현재 계약에서는 허용됩니다. in은 상속 속성도 찾습니다. JSON.parse로 얻은 일반 데이터가 아닌 임의의 객체까지 받는다면 own-property 정책이나 getter 접근 위험도 별도 설계해야 합니다. 여기서는 null·배열·빈 객체·NaN·Infinity·누락 이메일을 거절하고, 빈 이메일 문자열은 보존하는지 실행합니다.

아래 파일들은 한 프로젝트의 전체 코드입니다. 프로젝트 루트 기준 경로로 저장합니다. 시작·완성 내용이 같은 탭을 중복하지 않고 완성형으로 제공합니다.

현재 실습 ZIP

현재 본문과 같은 실습 파일 내려받기

narrowing.ts

type LookupResult =
  | { status: "found"; name: string; points: number }
  | { status: "missing"; reason: string };

interface Profile {
  name: string;
  points: number;
  email: string | null;
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function isProfile(value: unknown): value is Profile {
  if (!isRecord(value)) return false;
  return (
    "name" in value &&
    "points" in value &&
    "email" in value &&
    typeof value.name === "string" &&
    typeof value.points === "number" &&
    Number.isFinite(value.points) &&
    (typeof value.email === "string" || value.email === null)
  );
}

function parseProfile(value: unknown): Profile | null {
  return isProfile(value) ? value : null;
}

function summarize(result: LookupResult): string {
  if (result.status === "missing") return `조회 실패: ${result.reason}`;
  return `${result.name}: ${result.points}점`;
}

const rawValues: unknown[] = [
  { name: "서연", points: 95, email: null },
  { name: "도윤", points: "높음", email: "doyun@example.com" },
];

for (const raw of rawValues) {
  const profile = parseProfile(raw);
  const result: LookupResult = profile
    ? { status: "found", name: profile.name, points: profile.points }
    : { status: "missing", reason: "프로필 형식 오류" };
  console.log(summarize(result));
  console.log(profile?.email ?? "이메일 없음");
}

const invalid: unknown[] = [
  null,
  [],
  {},
  { name: "A", points: NaN, email: null },
  { name: "A", points: Infinity, email: null },
  { name: "A", points: 1 },
];
for (const value of invalid)
  if (parseProfile(value) !== null) throw new Error("잘못된 입력 허용");
const emptyEmail = parseProfile({ name: "A", points: 0, email: "" });
if (emptyEmail === null || (emptyEmail.email ?? "대체") !== "")
  throw new Error("빈 문자열 손실");
console.log("PASS 7 boundary checks");

compile-error.ts

function printLength(value: unknown): void {
  console.log(value.length);
}

function uppercase(name: string | null): string {
  return name.toUpperCase();
}

tsconfig.json

{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "module": "CommonJS",
    "outDir": "dist",
    "noEmitOnError": true,
    "types": []
  },
  "files": ["narrowing.ts"]
}

tsconfig.error.json

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "noEmit": true
  },
  "files": ["compile-error.ts"]
}

package.json

{
  "private": true,
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "start": "node dist/narrowing.js",
    "check:errors": "tsc -p tsconfig.error.json"
  },
  "devDependencies": {
    "typescript": "7.0.2"
  }
}
npm install
npm run build
npm start
npm run check:errors

실제 확인 결과:

서연: 95점
이메일 없음
조회 실패: 프로필 형식 오류
이메일 없음
PASS 7 boundary checks

npm run check:errors는 의도적으로 실패해야 합니다. 기대 오류는 TS18046·TS18047이며 정상 빌드 실패와 구분합니다.

검증 환경은 Node.js v24.19.0, TypeScript 7.0.2입니다. 설치되어 있던 의존성으로 타입 검사와 위 실행을 확인했습니다. 새 환경의 npm install, 실제 서버 응답, 브라우저 클릭·모바일 화면은 이 검증에 포함하지 않습니다.

공식 출처 확인일: 2026-09-12. narrowing.html

. 위 완성형 뷰어와 같은 코드이며 README와 검증 기록을 포함합니다.

완료 기준과 확장 연습

  • status 리터럴로 성공과 실패 union을 정확히 분기했습니다.
  • string과 null을 구분하고 대체 문구를 출력했습니다.
  • unknown 값을 객체, null, 배열, 각 속성 순서로 검사했습니다.
  • 타입 단언과 any 없이 올바른 타입 가드를 작성했습니다.
  • 정상·오류 입력의 실행 결과와 의도적 컴파일 오류를 각각 확인했습니다.

연습으로 email 필드를 생략 가능한 값으로 바꿔 보세요. 그러면 Profile 선언과 isProfile 검증에서 undefined를 허용할지 함께 결정해야 합니다. 다음으로 status에 loading 가지를 추가하고 summarize가 새 상태를 처리하게 만들어 보세요. 변경할 때마다 정상 입력과 잘못된 입력을 모두 다시 실행하고 출력이 의도와 같은지 확인해 주세요. 타입 선언만 바꾸거나 검사만 바꾸면 두 단계 중 하나에서 문제가 드러납니다. 선언, 런타임 검증, 사용 코드를 한 규칙으로 맞추는 것이 안전한 narrowing의 핵심입니다.

이 글이 도움이 되었나요?

조회 중

TypeScript 학습 순서

필수 11개 · 전체 11개

읽음 기록 관리

전체 과정 목차 (11개)
  1. 필수 학습 · TypeScript란 무엇인가: JavaScript와 차이 정리
  2. 필수 학습 · TypeScript 기본 타입: 객체와 함수까지 strict로 익히기
  3. 필수 학습 · TypeScript 타입 좁히기: union null unknown 안전 검증 현재 글
  4. 필수 길잡이 · TypeScript 실무 로드맵
  5. 필수 학습 · TypeScript as const 사용법: union 타입을 자동 생성하는 기준
  6. 필수 학습 · React TypeScript 기본 구조: tsx 파일과 props 타입 기준
  7. 필수 학습 · React TypeScript optional props 기본값 처리: undefined·null 차이
  8. 필수 학습 · TypeScript children 타입: ReactNode와 PropsWithChildren 차이
  9. 필수 학습 · TypeScript 제네릭 쉽게 이해하기: props와 API 응답 타입으로 감 잡기
  10. 필수 학습 · TypeScript 고급 상태 모델링: 판별 유니온과 never 타입 테스트
  11. 필수 학습 · TypeScript satisfies 사용법: 타입 지정·as const와 비교하기

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기