리터럴 타입 — 정확한 값으로 타입 좁히기

TypeScript 리터럴 타입(문자열·숫자·불리언)의 선언 방법, 타입 확장(widening), as const를 통한 리터럴 고정, 유니언과의 조합 패턴을 완전히 정리합니다.

· 11 min read · PALDYN Team

지난 글에서 const enum을 살펴봤다. enum을 컴파일 타임 상수로 다루는 방법을 이해했다면, 이번에는 그 기반이 되는 개념인 **리터럴 타입(Literal Types)**을 깊이 살펴볼 차례다. 리터럴 타입은 TypeScript 타입 시스템에서 가장 작은 단위의 타입으로, 정확한 값 자체를 타입으로 표현한다. 유니언, as const, 판별 유니언 등 TypeScript의 고급 패턴들이 모두 리터럴 타입을 기반으로 동작한다.

리터럴 타입이란

리터럴 타입은 특정 값 하나를 타입으로 나타낸 것이다. string 타입이 모든 문자열을 허용하는 반면, "hello" 리터럴 타입은 정확히 문자열 "hello"만 허용한다.

// 일반 타입 vs 리터럴 타입
let a: string = "hello";   // 어떤 문자열이든 OK
let b: "hello" = "hello";  // 오직 "hello"만 OK
let c: "hello" = "world";  // 오류: '"world"'는 '"hello"' 타입에 할당 불가

TypeScript에는 세 종류의 리터럴 타입이 있다: 문자열 리터럴, 숫자 리터럴, 불리언 리터럴. 그 외에도 null, undefined, symbol, bigint 리터럴 타입도 존재하지만 가장 자주 쓰이는 세 가지를 중심으로 살펴본다.

리터럴 타입 종류

문자열·숫자·불리언 리터럴

문자열 리터럴 타입은 특정 문자열 값을 타입으로 사용한다.

type YesOrNo = "yes" | "no";
type HttpVerb = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";

function request(method: HttpVerb, url: string) {
  // method는 반드시 위 다섯 가지 중 하나여야 함
}

request("GET", "/api/users");    // OK
request("FETCH", "/api/users");  // 오류 — "FETCH"는 HttpVerb 아님

숫자 리터럴 타입은 특정 숫자 값만 허용한다. 주사위 면처럼 취할 수 있는 값이 한정된 경우에 유용하다.

type DiceValue = 1 | 2 | 3 | 4 | 5 | 6;
type ZeroOrOne = 0 | 1;    // 플래그 값
type Port = 80 | 443 | 3000 | 8080;

function setFlag(flag: ZeroOrOne) { /* ... */ }
setFlag(1);   // OK
setFlag(0);   // OK
setFlag(2);   // 오류

불리언 리터럴 타입true 또는 false 값 자체를 타입으로 사용한다. 단독으로 쓰이기보다는 조건부 타입과 함께 자주 등장한다.

type True  = true;
type False = false;

// 조건부 타입에서 불리언 리터럴
type IsString<T> = T extends string ? true : false;

type A = IsString<"hello">; // true
type B = IsString<42>;      // false

리터럴 타입과 유니언

리터럴 타입은 단독으로 쓰이기보다 유니언 타입과 결합할 때 진가를 발휘한다. 허용 가능한 값의 집합을 정확히 표현할 수 있다.

// 상태 머신 타입
type TrafficLight = "red" | "yellow" | "green";

function nextLight(current: TrafficLight): TrafficLight {
  switch (current) {
    case "red":    return "green";
    case "green":  return "yellow";
    case "yellow": return "red";
  }
}

TypeScript는 switch 문에서 current가 어떤 값인지 각 case마다 정확히 알기 때문에 default 없이도 반환 타입이 완전히 커버됨을 확인한다.

// 이벤트 시스템
type EventName =
  | "click"
  | "focus"
  | "blur"
  | "keydown"
  | "keyup";

function addEventListener(
  event: EventName,
  handler: (e: Event) => void
) { /* ... */ }

addEventListener("click", (e) => { /* ... */ });  // OK
addEventListener("hover", (e) => { /* ... */ });  // 오류

유니언 리터럴 타입은 함수 오버로드 없이도 허용 가능한 인자를 정확히 문서화한다.

타입 확장(Widening)과 const

TypeScript는 변수를 선언할 때 **타입 확장(widening)**을 수행한다. 리터럴 값으로 초기화된 변수의 타입을 리터럴 타입 그대로 유지하지 않고, 더 넓은 기본 타입으로 확장하는 것이다.

let x = "hello";   // 추론된 타입: string (확장됨)
let y = 42;        // 추론된 타입: number (확장됨)
let z = true;      // 추론된 타입: boolean (확장됨)

let으로 선언하면 나중에 다른 값으로 재할당할 수 있으므로 TypeScript는 넓은 타입을 추론한다. 반면 const로 선언하면 재할당이 불가능하므로 리터럴 타입을 그대로 유지한다.

const x = "hello";  // 추론된 타입: "hello" (리터럴 유지)
const y = 42;       // 추론된 타입: 42 (리터럴 유지)
const z = true;     // 추론된 타입: true (리터럴 유지)

이 차이가 실무에서 문제가 되는 경우는 함수 인자를 전달할 때다.

type Direction = "north" | "south" | "east" | "west";

function move(dir: Direction) { /* ... */ }

let dir = "north";   // 타입: string
move(dir);           // 오류! string은 Direction에 할당 불가

const dir2 = "north"; // 타입: "north"
move(dir2);           // OK — "north"는 Direction의 멤버

let으로 선언된 dir은 타입이 string으로 확장되어 있으므로, Direction 타입을 기대하는 함수에 전달할 수 없다.

리터럴 타입 확장 (Widening)

as const로 리터럴 고정

객체나 배열에서 letconst의 동작은 다르다. const로 객체를 선언해도 속성은 여전히 변경 가능하므로, 속성 타입은 넓은 타입으로 추론된다.

const config = {
  env:  "production",
  port: 3000,
};
// 추론된 타입: { env: string; port: number }
// 속성이 리터럴이 아닌 이유: config.env = "development" 가 가능하기 때문

모든 속성을 리터럴 타입으로 고정하려면 as const를 사용한다.

const config = {
  env:  "production",
  port: 3000,
} as const;
// 추론된 타입: { readonly env: "production"; readonly port: 3000 }

as const는 두 가지 효과를 동시에 가져온다. 첫째, 모든 속성이 리터럴 타입으로 좁혀진다. 둘째, 모든 속성이 readonly가 되어 재할당이 불가능해진다.

배열에도 동일하게 적용된다.

const colors = ["red", "green", "blue"];
// 타입: string[]

const colors2 = ["red", "green", "blue"] as const;
// 타입: readonly ["red", "green", "blue"]
// — 리터럴 튜플 타입으로 고정됨

as const 배열은 단순한 string[]이 아니라 세 원소의 정확한 리터럴 타입을 가진 읽기 전용 튜플이 된다.

실전 패턴: 설정 객체, 상태 열거

패턴 1: 설정 객체로 enum 대체하기

const Role = {
  Admin:   "ADMIN",
  User:    "USER",
  Guest:   "GUEST",
} as const;

type Role = typeof Role[keyof typeof Role];
// "ADMIN" | "USER" | "GUEST"

function checkAccess(userRole: Role, required: Role) {
  return userRole === required;
}

checkAccess(Role.Admin, Role.User);   // OK
checkAccess("ADMIN",    "USER");      // OK — 리터럴 직접 전달도 OK
checkAccess("SUPERUSER", Role.User);  // 오류

이 패턴은 isolatedModules 환경에서도 안전하게 동작하며, 런타임에 객체 순회도 가능하다.

패턴 2: 판별 유니언의 판별자로 사용

type LoadingState = { status: "loading" };
type SuccessState = { status: "success"; data: string[] };
type ErrorState   = { status: "error";   message: string };

type State = LoadingState | SuccessState | ErrorState;

function render(state: State) {
  switch (state.status) {
    case "loading": return "로딩 중...";
    case "success": return state.data.join(", ");  // state: SuccessState
    case "error":   return `오류: ${state.message}`; // state: ErrorState
  }
}

리터럴 타입이 판별자 역할을 하여 TypeScript가 각 case에서 정확한 타입을 알 수 있다.

패턴 3: 함수 오버로드를 대체하는 조건부 반환 타입

function createElement(tag: "div"): HTMLDivElement;
function createElement(tag: "input"): HTMLInputElement;
function createElement(tag: "button"): HTMLButtonElement;
function createElement(tag: string): HTMLElement {
  return document.createElement(tag);
}

const div    = createElement("div");    // HTMLDivElement
const input  = createElement("input"); // HTMLInputElement

문자열 리터럴 타입을 오버로드 서명의 인자로 사용하면 반환 타입도 정확하게 좁힐 수 있다.

패턴 4: 경로 타입 안전성

const routes = {
  home:    "/",
  about:   "/about",
  contact: "/contact",
  profile: "/profile",
} as const;

type AppRoute = typeof routes[keyof typeof routes];
// "/" | "/about" | "/contact" | "/profile"

function navigate(path: AppRoute) {
  window.location.href = path;
}

navigate(routes.home);   // OK
navigate("/about");      // OK
navigate("/settings");   // 오류 — 정의되지 않은 경로

as const와 리터럴 타입으로 라우트 경로를 타입 안전하게 관리할 수 있다. 경로를 추가하거나 변경할 때 사용 지점에서 즉시 오류를 확인할 수 있어 리팩터링이 안전해진다.

리터럴 타입은 TypeScript 타입 시스템의 핵심 구성 요소다. 단순한 값 제한을 넘어 판별 유니언, 조건부 타입, 템플릿 리터럴 타입 등 고급 기능들의 토대가 된다. 변수 선언 시 const를 적극적으로 활용하고, 객체와 배열에는 as const를 적용하는 습관을 들이면 TypeScript의 타입 추론 품질이 크게 향상된다.


지난 글: const enum — 컴파일 타임 인라인과 트레이드오프

다음 글: 타입 추론 완전 정리 — TypeScript가 타입을 결정하는 방식


읽어주셔서 감사합니다. 😊