목표·선수 지식: 판별 유니온과 strict 검사에 익숙한 독자를 위한 전이 계약 완성 실습입니다. 본문의 짧은 문법 블록은 서로 독립된 부분 예제입니다. 같은 파일에 모두 합치면 중복 선언이 생길 수 있습니다.
상태와 이벤트의 조합을 컴파일러가 검사하게 만들기
판별 유니온을 상태 모델로 확장하고, 허용되는 전이만 공개 함수 계약에 담습니다. never로 새 상태의 처리 누락을 검출하며 런타임 테스트와 @ts-expect-error 타입 테스트를 나누어 검증합니다.
실습 경로: 현재 ZIP → 전체 코드. 먼저 설명에서 지정한 파일만 읽고, 설정·테스트 파일은 필요한 때 확인하세요.
학습 수준: TypeScript 고급 · 먼저 읽기: union·null·unknown 타입 좁히기, as const와 union 추출. 객체 타입, strict 검사, ES 모듈을 알고 있다고 가정합니다. React와 서버 없이 Node.js에서 실행합니다.
올바른 상태라고 해서 모든 이벤트를 받을 수 있는 것은 아닙니다
조회 화면을 loading: boolean, data?: string[], error?: string으로 표현하면 로딩과 오류가 동시에 켜지거나 성공인데 데이터가 없는 조합을 만들기 쉽습니다. 판별 유니온은 idle, loading, success, failure를 서로 다른 객체로 나누어 각 상태의 필수 데이터를 정합니다. 여기까지는 선수 글에서 익힌 좁히기의 응용입니다.
이번에는 상태 사이의 이동도 제한합니다. idle에는 start만 허용하고, loading에는 resolve·reject·reset을 허용합니다. 성공과 실패 뒤에는 start 또는 reset을 보낼 수 있습니다. loading 중의 재시작은 이 예제에서 금지하는 업무 규칙입니다. 검색어 변경마다 최신 요청을 시작하는 정책과 다르므로 실제 기능에 적용할 때는 먼저 전이표를 합의해야 합니다.
| 현재 상태 | 허용 이벤트 | 결과 |
|---|---|---|
| idle | start | loading |
| loading | resolve / reject | 같은 요청이면 success / failure, 다른 요청이면 유지 |
| loading | reset | idle |
| success / failure | start / reset | loading / idle |
단순히 transition(state: State, event: Event)만 공개하면 두 인수의 모든 조합이 허용됩니다. 따라서 공개 서명에는 [현재 상태, 허용 이벤트] 튜플들의 유니온을 사용합니다. 각 튜플이 전이표의 한 행입니다. 함수 본문은 넓은 State와 Event를 받아 구현하되 외부 호출에는 좁은 서명만 보이게 합니다. 이때 구현 서명이 호출자에게 보이지 않는 규칙은 TypeScript 함수 오버로드 문서에서 확인할 수 있습니다.
독립 실행 환경 준비
빈 실습 폴더에서 아래 명령을 실행합니다. 재현성을 위해 검증에 사용한 TypeScript 7.0.2을 고정합니다. 최신 버전이라는 뜻은 아닙니다. 기존 프로젝트의 의존성을 바꾸지 말고 독립 폴더를 사용하세요. Node.js 22 이상을 권장하며, Node에서 .ts를 직접 실행하는 방식은 이 실습의 타입 검사를 대신하지 않습니다.
npm init -y
npm install --save-dev typescript@7.0.2
npx tsc --version
다음 내용을 ts-state.config.json으로 저장합니다. files는 상태 구현·타입 테스트·회귀 검사 파일만 포함하므로 다른 학습용 오류 파일이 섞이지 않습니다. noEmitOnError는 오류가 있을 때 새 JavaScript 출력을 막습니다. types를 빈 배열로 지정해 주변 프로젝트에 설치된 전역 타입을 자동으로 가져오지 않으며, 이 예제는 Node 전용 모듈 타입에 의존하지 않습니다. ts-state.config.json 전체 코드
전체 ts-state.config.json는 아래 완성 코드에서 확인하고 복사하세요. ts-state.config.json 전체 코드
전체 구현과 런타임 검사: ts-state.ts
assertNever의 매개변수에는 어떤 실제 union 가지도 전달할 수 없습니다. 모든 case를 처리한 후 남는 값이 없을 때만 never가 되기 때문입니다. 이벤트 처리와 화면 설명 함수에 각각 이 검사를 둡니다. 첫 함수는 이벤트 추가 누락을, 둘째 함수는 상태 추가 누락을 찾는 자리입니다.
전체 ts-state.ts는 아래 완성 코드에서 확인하고 복사하세요. ts-state.ts 전체 코드
requestId가 number라는 정보만으로 두 요청의 번호가 같다는 사실까지 증명되지는 않습니다. 그래서 resolve와 reject에서는 실제 값을 비교합니다. 일치하지 않으면 기존 객체를 그대로 반환합니다. 빈 items는 잘못된 상태가 아니라 정상 성공 0개입니다. start의 양의 안전한 정수 검사도 타입 문법만으로 표현하지 않은 업무 규칙을 런타임에서 확인하는 예입니다.
오버로드는 허용되는 호출 형태를 검사하지만 함수 구현이 전이표를 정확히 따르는지 자동으로 증명하지는 않습니다. 그래서 타입 계약과 런타임 검사를 함께 둡니다. 예제의 상태·이벤트는 내부에서 만드는 신뢰된 객체입니다. JSON 같은 외부 입력을 바로 State나 Event로 단언해서 넣으면 이 계약을 우회하므로, 선수 글의 unknown 검증을 경계에 먼저 적용해야 합니다.
금지한 사용이 계속 금지되는지 검사: ts-state.types.ts
정상 코드의 타입 검사만으로는 계약이 느슨해진 회귀를 놓칠 수 있습니다. 예를 들어 AllowedTransition을 [State, Event]로 바꾸면 정상 예제는 계속 컴파일되지만 idle 상태의 resolve까지 허용됩니다. 아래 파일은 허용하지 않는 호출을 기록해 그 변경을 감지합니다.
전체 ts-state.types.ts는 아래 완성 코드에서 확인하고 복사하세요. ts-state.types.ts 전체 코드
@ts-expect-error는 다음 줄에 오류가 있어야 한다는 검사입니다. 오류가 사라지면 사용하지 않는 지시문이라는 컴파일 오류가 생깁니다. 모든 오류를 조용히 감추는 @ts-ignore와 목적이 다릅니다. 정확한 오류 코드 하나를 지정하는 기능은 아니므로 잘못된 호출을 한 줄씩 짧게 두고 주석에 금지 이유를 적습니다. 동작 근거는 TypeScript 3.9의 @ts-expect-error 설명을 참고하세요.
npx tsc -p ts-state.config.json
node ts-state-build/ts-state.js
첫 명령이 오류 없이 종료하면 정상 호출 두 개와 금지 호출 네 개가 기대대로 검사된 것입니다. 두 번째 명령은 PASS 6 runtime checks를 출력해야 합니다. 타입 테스트 함수는 호출하지 않습니다. 학습용 잘못된 호출을 실제 실행하지 않으면서 컴파일러로만 검사하기 위한 구조입니다. 컴파일이 실패하면 이전에 생성된 출력이 남아 있을 수 있으므로 반드시 첫 명령의 성공부터 확인합니다.
never가 누락을 찾는지 직접 실패시켜 보기
별도 실습본에서 State 마지막에 | { status: "cancelled" }를 추가하고 describe 함수는 그대로 두세요. 컴파일하면 describe의 assertNever(state)에서 cancelled 객체를 never에 전달할 수 없다는 오류가 발생해야 합니다. cancelled case를 추가하면 그 오류가 사라집니다. 그다음 전이표에서 어느 이벤트가 cancelled로 가는지 별도로 설계해야 합니다. 출력 분기를 추가한 것만으로 전이 정책까지 완성되지는 않습니다.
반대로 default에서 그냥 “알 수 없음”을 반환하면 새 상태가 들어와도 컴파일이 성공할 수 있습니다. fallback 화면이 필요한 제품도 있지만, 개발 중 누락 검출이 목적이라면 never를 유지해야 합니다. 공식 Narrowing 문서의 exhaustiveness checking에서 남은 union 가지를 never로 확인하는 원리를 볼 수 있습니다.
경계값과 확장 과제
완료 기준은 타입 검사 성공, 여섯 런타임 검사 통과, cancelled 가지 추가 시 의도적 컴파일 실패를 각각 확인하는 것입니다. 또한 requestId가 0일 때의 거절, 오래된 번호의 성공 응답 무시, 빈 결과의 성공 처리를 구분해 설명할 수 있어야 합니다. 타입 오류가 없다는 결과와 실행 결과가 맞다는 결과는 서로 다른 증거입니다.
확장 과제는 loading 상태에서만 cancel 이벤트를 허용하고 cancelled 상태에 취소 이유를 저장하는 것입니다. State, Event, AllowedTransition, transition, describe와 타입 테스트를 모두 갱신하세요. 통과 조건은 loading의 cancel이 성공하고, idle의 cancel은 컴파일 실패하며, cancelled 화면에 이유가 표시되는 것입니다. 추가로 cancelled에서 start를 허용할지 결정해 전이표와 코드가 일치하는지 확인합니다.
이 예제는 타입으로 상태 간 허용 관계를 표현하는 작은 상태 머신이며 네트워크 취소를 수행하지는 않습니다. 실제 비동기 계층에서는 AbortController로 작업을 중단하고, 그 결과를 검증된 이벤트로 전달하는 연결이 필요합니다. 상태 모델이 커져 전이가 수십 개로 늘어나면 표와 구현의 중복 비용도 커지므로 전이 정의를 한곳에 모으는 방식이나 검증된 상태 관리 도구를 별도로 평가할 수 있습니다.
출처 확인일: 2026-09-12. 상태 모델과 테스트는 이 실습의 예제이며 언어 동작의 근거는 본문에 연결한 TypeScript 공식 문서입니다.
직접 확인하는 경계와 재현 방법
추가 regression.ts는 오래된 실패 응답이 같은 객체를 유지하는지, 성공 결과 배열이 호출자 배열과 분리되는지, 성공·실패 뒤 reset과 start가 가능한지 검사합니다. 이는 기존 여섯 검사에서 비어 있던 전이와 별칭 경계를 채웁니다.
아래 파일들은 한 프로젝트의 전체 코드입니다. 프로젝트 루트 기준 경로로 저장합니다. 시작·완성 내용이 같은 탭을 중복하지 않고 완성형으로 제공합니다.
현재 실습 ZIP
ts-state.config.json
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "ts-state-build",
"noEmitOnError": true,
"types": []
},
"files": ["ts-state.ts", "ts-state.types.ts", "regression.ts"]
}
ts-state.ts
export type Idle = { status: "idle" };
export type Loading = { status: "loading"; requestId: number };
export type Success = { status: "success"; items: readonly string[] };
export type Failure = { status: "failure"; message: string };
export type State = Idle | Loading | Success | Failure;
type Start = { type: "start"; requestId: number };
type Resolve = { type: "resolve"; requestId: number; items: readonly string[] };
type Reject = { type: "reject"; requestId: number; message: string };
type Reset = { type: "reset" };
type Event = Start | Resolve | Reject | Reset;
type AllowedTransition =
| [Idle, Start]
| [Loading, Resolve | Reject | Reset]
| [Success | Failure, Start | Reset];
export function assertNever(value: never): never {
throw new Error(`처리하지 않은 가지: ${JSON.stringify(value)}`);
}
// 호출자는 이 서명만 사용합니다. 구현 서명은 외부에 노출되지 않습니다.
export function transition(...args: AllowedTransition): State;
export function transition(state: State, event: Event): State {
switch (event.type) {
case "start":
if (state.status === "loading") throw new Error("이미 요청 중입니다.");
if (!Number.isSafeInteger(event.requestId) || event.requestId < 1)
throw new Error("requestId는 양의 안전한 정수여야 합니다.");
return { status: "loading", requestId: event.requestId };
case "resolve":
if (state.status !== "loading") throw new Error("요청이 없습니다.");
if (state.requestId !== event.requestId) return state;
return { status: "success", items: [...event.items] };
case "reject":
if (state.status !== "loading") throw new Error("요청이 없습니다.");
if (state.requestId !== event.requestId) return state;
return { status: "failure", message: event.message };
case "reset":
if (state.status === "idle") throw new Error("이미 초기 상태입니다.");
return { status: "idle" };
default:
return assertNever(event);
}
}
export function describe(state: State): string {
switch (state.status) {
case "idle":
return "대기";
case "loading":
return `요청 ${state.requestId} 로딩`;
case "success":
return `결과 ${state.items.length}개`;
case "failure":
return `실패: ${state.message}`;
default:
return assertNever(state);
}
}
function check(actual: unknown, expected: unknown): void {
if (JSON.stringify(actual) !== JSON.stringify(expected))
throw new Error(
`예상 ${JSON.stringify(expected)}, 실제 ${JSON.stringify(actual)}`,
);
}
const idle: Idle = { status: "idle" };
const loading = transition(idle, { type: "start", requestId: 1 });
check(describe(loading), "요청 1 로딩");
// 반환값 State를 검사해야 resolve 이벤트를 보낼 수 있습니다.
if (loading.status !== "loading") throw new Error("로딩 상태가 필요합니다.");
check(
transition(loading, { type: "resolve", requestId: 99, items: ["과거"] }),
loading,
);
const success = transition(loading, {
type: "resolve",
requestId: 1,
items: [],
});
check(describe(success), "결과 0개");
const failure = transition(loading, {
type: "reject",
requestId: 1,
message: "연결 실패",
});
check(describe(failure), "실패: 연결 실패");
check(transition(loading, { type: "reset" }), idle);
let rejected = false;
try {
transition(idle, { type: "start", requestId: 0 });
} catch {
rejected = true;
}
check(rejected, true);
console.log("PASS 6 runtime checks");
ts-state.types.ts
import { transition, type Idle, type Loading, type State } from "./ts-state.js";
// 이 함수는 실행하지 않고 컴파일러로만 검사합니다.
function typeTests(idle: Idle, loading: Loading, state: State): void {
transition(idle, { type: "start", requestId: 1 });
transition(loading, { type: "resolve", requestId: 1, items: [] });
// @ts-expect-error idle에서는 완료 이벤트를 보낼 수 없습니다.
transition(idle, { type: "resolve", requestId: 1, items: [] });
// @ts-expect-error loading에서는 새 요청을 시작할 수 없습니다.
transition(loading, { type: "start", requestId: 2 });
// @ts-expect-error 성공 상태에는 items가 반드시 있어야 합니다.
const invalid: State = { status: "success" };
// @ts-expect-error 어떤 상태인지 좁히기 전에는 완료시킬 수 없습니다.
transition(state, { type: "resolve", requestId: 1, items: [] });
void invalid;
}
void typeTests;
regression.ts
import {
transition,
type Loading,
type Success,
type Failure,
} from "./ts-state.js";
const loading: Loading = { status: "loading", requestId: 2 };
const items = ["원본"];
const done = transition(loading, { type: "resolve", requestId: 2, items });
items.push("나중 변경");
if (done.status !== "success" || done.items.length !== 1)
throw new Error("배열 별칭 누출");
if (
transition(loading, { type: "reject", requestId: 1, message: "과거" }) !==
loading
)
throw new Error("오래된 실패 응답 처리 오류");
for (const state of [
{ status: "success", items: [] } satisfies Success,
{ status: "failure", message: "실패" } satisfies Failure,
]) {
if (transition(state, { type: "reset" }).status !== "idle")
throw new Error("reset");
if (transition(state, { type: "start", requestId: 3 }).status !== "loading")
throw new Error("retry");
}
console.log("PASS copy, stale reject, reset and retry checks");
package.json
{
"private": true,
"scripts": {
"build": "tsc -p ts-state.config.json",
"start": "node ts-state-build/regression.js"
},
"devDependencies": {
"typescript": "7.0.2"
}
}
npm install
npm run build
npm start
실제 확인 결과:
PASS 6 runtime checks
PASS copy, stale reject, reset and retry checks
검증 환경은 Node.js v24.19.0, TypeScript 7.0.2입니다. 설치되어 있던 의존성으로 타입 검사와 위 실행을 확인했습니다. 새 환경의 npm install, 실제 서버 응답, 브라우저 클릭·모바일 화면은 이 검증에 포함하지 않습니다.
공식 출처 확인일: 2026-09-12. narrowing.html · functions.html#function-overloads · typescript-3-9.html
. 위 완성형 뷰어와 같은 코드이며 README와 검증 기록을 포함합니다.
이 글이 도움이 되었나요?
TypeScript 학습 순서
필수 11개 · 전체 11개
읽음 기록 관리
전체 과정 목차 (11개)
- 필수 학습 · TypeScript란 무엇인가: JavaScript와 차이 정리
- 필수 학습 · TypeScript 기본 타입: 객체와 함수까지 strict로 익히기
- 필수 학습 · TypeScript 타입 좁히기: union null unknown 안전 검증
- 필수 길잡이 · TypeScript 실무 로드맵
- 필수 학습 · TypeScript as const 사용법: union 타입을 자동 생성하는 기준
- 필수 학습 · React TypeScript 기본 구조: tsx 파일과 props 타입 기준
- 필수 학습 · React TypeScript optional props 기본값 처리: undefined·null 차이
- 필수 학습 · TypeScript children 타입: ReactNode와 PropsWithChildren 차이
- 필수 학습 · TypeScript 제네릭 쉽게 이해하기: props와 API 응답 타입으로 감 잡기
- 필수 학습 · TypeScript 고급 상태 모델링: 판별 유니온과 never 타입 테스트 현재 글
- 필수 학습 · TypeScript satisfies 사용법: 타입 지정·as const와 비교하기
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.