TypeScript 고급 상태 모델링: 판별 유니온과 never 타입 테스트

··설명 약 7분·작성: 해비·블로그 소개

TypeScript 판별 유니온과 never로 불가능한 요청 상태와 빠진 분기를 어떻게 찾나요?

요청 상태를 여러 boolean으로 만들면 대기와 성공이 동시에 켜지는 불가능한 조합도 허용됩니다. status 같은 공통 필드를 가진 판별 유니온으로 가능한 상태만 표현하고, never 검사로 처리하지 않은 분기를 찾습니다.

공식 문서 참고: TypeScript 핸드북: 타입 좁히기·판별 유니온·never

직접 확인: 오류에서 수정까지

TypeScript 고급 상태 모델링: 판별 유니온과 never 타입 테스트 · 독립 실습

얻을 결과: status를 기준으로 나눈 뒤 default에서 never 검사를 걸어 미처리 상태를 찾습니다.

예제 크게 보기 (새 탭)

단독 검사는 tsconfig.json이 없는 새 폴더에서 example.ts 한 파일씩 교체하며 진행합니다. TypeScript가 없다면 설치 글에서 Node.js/npm·TypeScript 준비를 확인하세요. 타입 검사 성공은 코드 실행 성공과 구분합니다.

독자 질문: 새로운 요청 상태를 추가했을 때 빠진 분기를 어떻게 발견할까요?

얻을 결과: status를 기준으로 나눈 뒤 default에서 never 검사를 걸어 미처리 상태를 찾습니다.

다음 코드를 별도 example.ts에 복사합니다.

type State = { status: 'loading' } | { status: 'success'; data: string } | { status: 'error'; message: string };
function render(state: State): string {
  switch (state.status) {
    case 'loading': return '로딩';
    case 'success': return state.data;
    default: { const exhaustive: never = state; return exhaustive; }
  }
}

예상 오류 (TS2322): default의 state가 never가 아니라 error 상태로 남습니다. error 분기를 추가합니다.

오류 예제를 다음 코드로 교체합니다.

type State = { status: 'loading' } | { status: 'success'; data: string } | { status: 'error'; message: string };
function render(state: State): string {
  switch (state.status) {
    case 'loading': return '로딩';
    case 'success': return state.data;
    case 'error': return state.message;
    default: { const exhaustive: never = state; return exhaustive; }
  }
}
console.log(render({ status: 'error', message: '실패' }));
npx tsc --strict --noEmit example.ts

확인 결과: TypeScript 7.0.2의 별도 최소 예제에서 오류 TS2322, 수정 후 exit 0 확인. TaskFlow 전체 체크포인트는 미확인.

다음 학습: 16회차에서 외부 JSON을 unknown으로 받고 runtime에 검사합니다.

상태를 union으로 모델링

아래 개념 조각에서 참조하는 학습용 타입입니다. 프로젝트 파일을 import하지 않습니다. 같은 이름을 다시 선언하는 비교 코드는 별도 파일에서 확인하세요.

type Task = { id: string; title: string; completed: boolean };
type TaskLoadState =
  | {
      status: 'idle';
    }
  | {
      status: 'loading';
    }
  | {
      status: 'success';
      data: Task[];
    }
  | {
      status: 'error';
      error: Error;
    };

success에만 data가 있고 error에만 error가 있습니다.

불가능한 조합을 타입에서 제거했습니다.

narrowing

function printState(
  state:
    TaskLoadState,
) {
  if (
    state.status ===
      'success'
  ) {
    console.log(
      state.data,
    );
  }
}

status가 success인 branch에서 data 접근이 허용됩니다.

switch

function getMessage(
  state:
    TaskLoadState,
): string {
  switch (state.status) {
    case 'idle':
      return '대기';
    case 'loading':
      return '불러오는 중';
    case 'success':
      return `${state.data.length}개`;
    case 'error':
      return state.error.message;
  }
}

모든 상태가 처리됩니다.

never exhaustive check

상태가 추가됐을 때 switch 처리를 빼먹지 않게 만들 수 있습니다.

function assertNever(
  value: never,
): never {
  throw new Error(
    '처리되지 않은 상태',
  );
}
function getMessage(
  state:
    TaskLoadState,
): string {
  switch (state.status) {
    case 'idle':
      return '대기';
    case 'loading':
      return '로딩';
    case 'success':
      return '완료';
    case 'error':
      return '오류';
    default:
      return assertNever(
        state,
      );
  }
}

새 status를 union에 추가하고 switch에 추가하지 않으면 state가 never가 될 수 없으므로 오류가 발생합니다.

never를 억지로 직접 쓰지 않기

never는 “절대 발생할 수 없는 값”을 표현합니다.

실무에서는 exhaustive check, 함수가 절대 정상 종료하지 않는 경우 등에 자연스럽게 등장합니다.

고급 타입 트릭을 위해 무조건 사용할 필요는 없습니다.

학습 완료 체크리스트

  • 판별 유니온으로 상태를 모델링할 수 있습니다.
  • 상태별 전용 property를 설계할 수 있습니다.
  • switch에서 narrowing을 사용할 수 있습니다.
  • never exhaustive check를 만들 수 있습니다.
  • 불가능한 boolean 조합을 타입에서 제거할 수 있습니다.

작은 변형으로 확인하기

State에 { status: ‘idle’ }을 추가하면 default의 never 할당이 실패합니다. idle 분기를 더하면 다시 통과해야 합니다.

선택 실습: 할 일 앱에 적용하기

TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 JavaScript 코드를 TypeScript로 확장합니다.

TaskFlow의 데이터 로딩 상태를 boolean 여러 개에서 판별 유니온으로 정리합니다.

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

TaskFlow 프로젝트 적용

TypeScript 고급 상태 모델링: 판별 유니온과 never 타입 테스트 · 단계별 TaskFlow

타입을 적용한 TaskFlow에서 작업을 추가·완료 전환·삭제하세요. 타입만 바뀐 단계는 이전과 화면이 같습니다.

예제 크게 보기 (새 탭)

변경 설명과 부분 예제를 읽은 뒤, 시작·완료 파일과 적용 순서에서 필요한 선언·import·화면 연결을 포함한 전체 파일을 확인하세요. 이번 단계의 변경 이유와 적용 위치도 함께 확인합니다.

시작본과 이번 변경

TypeScript Conditional Type과 infer 조건에 따라 타입 만들기의 완료 체크포인트를 기준으로 이어갑니다. 변경하지 않는 파일은 그 시작 상태를 유지합니다.

src/types/state.ts에 Task를 import한 뒤 TaskLoadState를 선언합니다. 기존 여러 boolean을 status union으로 정리하고 메시지 함수의 각 분기를 대응시킵니다. 새 status를 추가해 누락 검사 후 정상 분기를 복원합니다.

이전 체크포인트

typescript-step-14-conditional-types

이번 목표

TaskFlow의 데이터 로딩 상태를 boolean 여러 개에서 판별 유니온으로 정리합니다.

전체 파일·코드 보기

src/types/state.ts

export type TaskLoadState =
  | {
      status: 'idle';
    }
  | {
      status: 'loading';
    }
  | {
      status: 'success';
      data: Task[];
    }
  | {
      status: 'error';
      error: Error;
    };

메시지 함수

export function getStateMessage(
  state:
    TaskLoadState,
): string {
  switch (state.status) {
    case 'idle':
      return '대기 중';
    case 'loading':
      return '불러오는 중';
    case 'success':
      return `${state.data.length}개 작업`;
    case 'error':
      return state.error.message;
  }
}

Before / After

Before
isLoading
isSuccess
isError
data?
error?


After
status 하나를 기준으로
가능한 상태만 union으로 표현

검증

npx tsc --noEmit

중간부터 시작하는 전체 파일과 적용 순서

이번 글 시작 ZIP · 이번 글 완료 ZIP

시작 ZIP은 직전 프로젝트 단계 typescript-step-14-conditional-types의 실제 완료 파일입니다. 프로젝트 순서를 이어가는 기준이며 사이트의 기술 읽기 순서와는 구분합니다.

  1. 시작 ZIP을 새 폴더에 풉니다. 이 폴더가 작업 루트이며 기존 체크포인트 파일을 별도로 찾을 필요가 없습니다.
  2. 상태 유니온 파일의 누락 import를 보완하고 상태별 메시지 함수를 연결합니다.
  3. 아래 변경 파일을 같은 상대 경로에 추가하거나 전체 교체합니다. 표시하지 않은 파일과 images는 시작본 그대로 유지합니다. package-lock.json은 완료 ZIP에 있는 파일을 함께 적용합니다.
  4. 작업 루트에서 명령을 실행하고 예상 결과를 확인합니다. 완료 ZIP은 직접 적용한 결과를 비교하는 기준입니다.

추가·교체 파일

  • CHANGES.md — 전체 교체
  • README.md — 전체 교체
  • tests/contracts.ts — 전체 교체
  • src/types/state.ts — 추가
  • src/core/state.ts — 추가

실행과 확인

npm ci
npm run dev
npm run typecheck
npm test
npm run build

개발 서버는 실행 상태로 두고 다른 터미널에서 검사 명령을 실행하세요. Node.js 24.18.0에서 검증했습니다. JavaScript는 http://127.0.0.1:4173, TypeScript는 Vite 실행 출력의 주소로 접속합니다.

예상 결과: idle/loading/success/error 네 상태의 메시지를 확인하며 success 빈 배열은 0개 작업입니다.

실제 검증: TypeScript strict 검사·긍정/부정 타입 계약·런타임 검사·Vite 빌드 통과. 시작 ZIP에 이 글의 변경 파일만 적용한 결과와 완료 ZIP의 모든 파일 내용이 일치했습니다. 브라우저 화면 배치·실제 기기·외부 CDN 가용성은 이 검사에 포함하지 않았습니다.

이 절의 전체 파일은 ZIP과 일치합니다. 위쪽 원문의 짧은 예제나 생략 표시가 있는 부분 코드는 개념·변경 위치 설명용입니다. 서버 저장과 인증은 제공하지 않으며 JSON fixture는 로컬 읽기 실습용입니다.

복사할 변경 파일 전체 코드

tests/contracts.ts — 전체 파일

import type { Task } from '../src/types/task.js';
const valid: Task = {id:'1',title:'공부',completed:false};
// @ts-expect-error completed must be boolean
const invalid: Task = {id:'1',title:'공부',completed:'no'};
void valid; void invalid;
import { readTitle } from '../src/core/selection.js';
const nullable = readTitle(null);
// @ts-expect-error null result must be narrowed
const definite: string = nullable;
void definite;
import type { TaskKey } from '../src/types/task-key.js';
// @ts-expect-error nonexistent property
const badKey: TaskKey = 'missing';
void badKey;
import { TASK_FILTER_LABELS } from '../src/constants/task.js';
// @ts-expect-error missing is not a filter key
const badLabel = TASK_FILTER_LABELS.missing;
void badLabel;
import { getValue } from '../src/core/collection.js';
// @ts-expect-error nonexistent key must fail
getValue({title:'ok'}, 'missing');
import type { TaskFormDirty } from '../src/types/form.js';
// @ts-expect-error completed is required by mapped type
const dirty: TaskFormDirty = {title:true};
void dirty;
import type { AsyncResult } from '../src/types/async.js';
const result: AsyncResult<Promise<Task[]>> = [];
// @ts-expect-error Promise value must resolve to Task array
const wrongResult: AsyncResult<Promise<Task[]>> = 1;
void result;void wrongResult;
import type { TaskLoadState } from '../src/types/state.js';
// @ts-expect-error success requires data
const wrongState: TaskLoadState = {status:'success'};
void wrongState;

src/types/state.ts — 전체 파일

import type { Task } from './task.js';
export type TaskLoadState =
  | {
      status: 'idle';
    }
  | {
      status: 'loading';
    }
  | {
      status: 'success';
      data: Task[];
    }
  | {
      status: 'error';
      error: Error;
    };

src/core/state.ts — 전체 파일

import type { TaskLoadState } from '../types/state.js';
export function getStateMessage(
  state:
    TaskLoadState,
): string {
  switch (state.status) {
    case 'idle':
      return '대기 중';
    case 'loading':
      return '불러오는 중';
    case 'success':
      return `${state.data.length}개 작업`;
    case 'error':
      return state.error.message;
  }
}

완료 체크포인트

typescript-step-15-state-modeling

다음 학습

다음 글에서는 외부 API JSON을 타입 선언만 믿지 않고 unknown에서 실제 Task로 검증하는 type guard를 완성합니다.

다음 글: TypeScript 외부 API 데이터 unknown과 type guard 안전 검증

글 소개·학습 준비·전체 목차
TypeScript 고급 상태 모델링: 판별 유니온과 never 타입 테스트

TypeScript · 15 / 22 · 고급 학습 · 판별 유니온과 never

여러 boolean으로 UI 상태를 표현하면 실제로 불가능한 조합이 만들어질 수 있습니다.

type State = {
  loading: boolean;
  success: boolean;
  error: boolean;
};

loading과 success가 동시에 true인 상태도 타입상 허용됩니다.

판별 유니온을 사용하면 가능한 상태만 타입으로 표현할 수 있습니다.

이번 글에서 배우는 내용

  • discriminated union
  • 상태별 전용 property
  • switch narrowing
  • never
  • exhaustive check
  • 불가능한 상태 제거

단독 학습 선수 지식

union·narrowing·switch와 함수 반환 타입을 알면 됩니다. 이전 TaskFlow 파일 없이 직접 확인 예제로 학습할 수 있습니다.

목차

이 글이 도움이 되었나요?

조회 중

읽음 기록

읽음 — / 전체 22개 · 필수 21개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

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

RSS 피드 구독하기

댓글 남기기