🔥 리터럴 타입: "hello" 가 타입이다
강의 목차

IDE 호버에서 시작한 의문
작업 폴더에서 bunx tsc --noEmit을 돌리기 전에 IDE 호버를 확인했다. let greetingLet = "hello"는 string으로 나왔다. 바로 아래 const greetingConst = "hello"는 "hello"로 남았다. 둘 다 같은 문자열을 대입했는데 결과가 달랐다. 그때 내 반응은 단순했다. 왜 같은 "hello"를 넣었는데 하나는 넓어지고 하나는 그대로 남는가.
"hello"는 평소 값 식에 놓던 표기다. TypeScript에서는 같은 표기가 타입 표기에도 들어간다. 값 쪽에서 확인하던 문자열을 타입 쪽에서 다시 확인하는 구조라서, 이 지점부터 let과 const의 추론 차이가 덜 우연처럼 보인다.

문자열 리터럴이 타입 표기에 그대로 들어간다
문자열 리터럴 타입은 문자열 값 자체를 타입 표기에 넣는다. type Greeting = "hello"는 "hello"만 받을 수 있는 타입을 선언한다. 다른 문자열은 같은 string 계열이어도 통과하지 못한다.
// file: let-vs-const.ts
export {};
type Greeting = "hello";
function greetLiteral(greetingValue: Greeting): string {
return `Hi, ${greetingValue}`;
}
let greetingLet = "hello";
const greetingConst = "hello";
const greetingFromConst = greetLiteral(greetingConst);
// const greetingFromLet = greetLiteral(greetingLet);
greetingLet = "hi";// file: let-vs-const.ts
export {};
type Greeting = "hello";
function greetLiteral(greetingValue: Greeting): string {
return `Hi, ${greetingValue}`;
}
let greetingLet = "hello";
const greetingConst = "hello";
const greetingFromConst = greetLiteral(greetingConst);
// const greetingFromLet = greetLiteral(greetingLet);
greetingLet = "hi";TS 6.0 strict 기본 설정에서 주석을 해제하면 greetingLet 호출에서 진단이 나온다.
Argument of type 'string' is not assignable to parameter of type '"hello"'.Argument of type 'string' is not assignable to parameter of type '"hello"'.greetingLet의 값은 지금 "hello"지만 타입은 string이다. 컴파일러는 나중에 "hi" 같은 다른 문자열을 받을 수 있다고 판단한다. 반대로 greetingConst는 재할당할 수 없으므로 "hello"로 좁게 남는다.
리터럴 유니온은 여러 리터럴 값을 나열해 허용 범위를 좁힌다. 추론을 다룬 편에서 "light" | "dark" 같은 명시가 추론을 좁힌다고 한 줄로 적었다. 여기서 "light"와 "dark"는 문자열 값이면서 타입 표기에 들어가는 리터럴이다.
// file: string-literal-union.ts
export {};
type ThemeName = "light" | "dark";
function setThemeLiteral(themeValue: ThemeName): string {
return `theme:${themeValue}`;
}
const selectedTheme = setThemeLiteral("dark");
// setThemeLiteral("dim");// file: string-literal-union.ts
export {};
type ThemeName = "light" | "dark";
function setThemeLiteral(themeValue: ThemeName): string {
return `theme:${themeValue}`;
}
const selectedTheme = setThemeLiteral("dark");
// setThemeLiteral("dim");setThemeLiteral("dim")은 멈춘다. "dim"은 string이지만 "light" | "dark" 안에는 없다. 리터럴 유니온은 허용할 값을 정확히 나열하는 타입 표기다.

숫자와 boolean 리터럴도 같은 규칙이다
문자열만 특별한 예외가 아니다. 숫자 리터럴도 타입 표기에 그대로 들어간다. 주사위 값은 좋은 예다. 가능한 값이 1부터 6까지라면 숫자 전체를 받을 필요가 없다.
// file: numeric-literal.ts
export {};
type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;
function scoreDiceLiteral(rollValue: DiceRoll): number {
return rollValue * 10;
}
const diceScore = scoreDiceLiteral(4);
// scoreDiceLiteral(7);
type StrictTrue = true;
const strictYes: StrictTrue = true;
// const strictNo: StrictTrue = false;
type BooleanPair = true | false;
const booleanPairValue: BooleanPair = false;// file: numeric-literal.ts
export {};
type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;
function scoreDiceLiteral(rollValue: DiceRoll): number {
return rollValue * 10;
}
const diceScore = scoreDiceLiteral(4);
// scoreDiceLiteral(7);
type StrictTrue = true;
const strictYes: StrictTrue = true;
// const strictNo: StrictTrue = false;
type BooleanPair = true | false;
const booleanPairValue: BooleanPair = false;scoreDiceLiteral(7)은 멈춘다. 진단은 숫자 리터럴에도 같은 방식으로 나온다.
Argument of type '7' is not assignable to parameter of type 'DiceRoll'.Argument of type '7' is not assignable to parameter of type 'DiceRoll'.true와 false도 리터럴 타입이다. type StrictTrue = true는 true만 허용한다. true | false는 boolean과 같은 값 집합을 만든다. 그래서 boolean은 넓은 표기이고, true와 false는 좁은 표기다.

let 은 widen, const 는 좁게 남는다
widening은 재할당 가능성과 관련이 있다. let 변수는 나중에 다른 값을 받을 수 있다. 그래서 컴파일러는 let greetingLet = "hello"를 "hello" 하나로 묶지 않고 string으로 넓힌다. 나중에 greetingLet = "hi"를 허용해야 하기 때문이다.
const 변수는 재할당할 수 없다. 그래서 초기화 값 하나로 타입을 좁혀도 이후 대입과 충돌하지 않는다. const greetingConst = "hello"가 "hello"로 남는 이유는 이 제약에서 나온다.
객체 리터럴에서는 바인딩과 속성을 따로 구분해야 한다. const themeNoConst = { mode: "dark" }에서 const는 변수 바인딩을 고정한다. 하지만 themeNoConst.mode = "light"처럼 속성은 바꿀 수 있다. 그래서 컴파일러는 mode를 "dark"가 아니라 string으로 추론한다.

객체와 배열 리터럴 전체를 as const 로 고정한다
as const는 런타임 값을 바꾸지 않는다. 타입 추론에 대한 지시다. 객체 리터럴 전체를 가장 좁은 리터럴과 readonly 속성으로 추론한다. 배열 리터럴은 readonly 튜플로 추론한다.
// file: as-const.ts
export {};
const themeNoConst = { mode: "dark", level: 3 };
themeNoConst.mode = "light";
const themeConst = { mode: "dark", level: 3 } as const;
// themeConst.mode = "light";
const tagsNoConst = ["primary", "ghost"];
tagsNoConst.push("quiet");
const tagsConst = ["primary", "ghost"] as const;// file: as-const.ts
export {};
const themeNoConst = { mode: "dark", level: 3 };
themeNoConst.mode = "light";
const themeConst = { mode: "dark", level: 3 } as const;
// themeConst.mode = "light";
const tagsNoConst = ["primary", "ghost"];
tagsNoConst.push("quiet");
const tagsConst = ["primary", "ghost"] as const;themeNoConst는 대략 { mode: string; level: number }로 추론한다. const로 선언했지만 mode 속성은 바꿀 수 있다. 그래서 themeNoConst.mode = "light"는 통과한다.
themeConst는 대략 { readonly mode: "dark"; readonly level: 3 }로 추론한다. mode 값은 "dark"로 좁게 남고, 속성은 readonly가 된다. 주석을 해제하면 다음 진단이 나온다.
Cannot assign to 'mode' because it is a read-only property.Cannot assign to 'mode' because it is a read-only property.배열도 같은 방식이다. tagsNoConst는 string[]로 추론한다. 새 문자열을 넣을 수 있다. tagsConst는 readonly ["primary", "ghost"]로 추론한다. 컴파일러는 길이와 각 원소의 리터럴 값을 타입에 넣는다.

마지막 메모
이 글을 쓰고 나서 나는 IDE 호버의 string과 "hello"를 더 구체적인 증거로 읽게 됐다. 같은 값이라도 재할당 가능성, 속성 변경 가능성, as const 유무에 따라 컴파일러가 타입의 폭을 다르게 선택한다.











