🔥 리터럴 타입: "hello" 가 타입이다

786자
9분

문자열 리터럴 hello 가 값 식과 타입 표기에 같은 철자로 등장하는 두 패널 일러스트

IDE 호버에서 시작한 의문

작업 폴더에서 bunx tsc --noEmit을 돌리기 전에 IDE 호버를 확인했다. let greetingLet = "hello"string으로 나왔다. 바로 아래 const greetingConst = "hello""hello"로 남았다. 둘 다 같은 문자열을 대입했는데 결과가 달랐다. 그때 내 반응은 단순했다. 왜 같은 "hello"를 넣었는데 하나는 넓어지고 하나는 그대로 남는가.

"hello"는 평소 값 식에 놓던 표기다. TypeScript에서는 같은 표기가 타입 표기에도 들어간다. 값 쪽에서 확인하던 문자열을 타입 쪽에서 다시 확인하는 구조라서, 이 지점부터 letconst의 추론 차이가 덜 우연처럼 보인다.

IDE 호버가 let 변수에는 string 을 표시하고 const 변수에는 hello 리터럴을 표시하는 화면 일러스트

문자열 리터럴이 타입 표기에 그대로 들어간다

문자열 리터럴 타입은 문자열 값 자체를 타입 표기에 넣는다. type Greeting = "hello""hello"만 받을 수 있는 타입을 선언한다. 다른 문자열은 같은 string 계열이어도 통과하지 못한다.

ts
// 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
// 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 호출에서 진단이 나온다.

txt
Argument of type 'string' is not assignable to parameter of type '"hello"'.
txt
Argument of type 'string' is not assignable to parameter of type '"hello"'.

greetingLet의 값은 지금 "hello"지만 타입은 string이다. 컴파일러는 나중에 "hi" 같은 다른 문자열을 받을 수 있다고 판단한다. 반대로 greetingConst는 재할당할 수 없으므로 "hello"로 좁게 남는다.

리터럴 유니온은 여러 리터럴 값을 나열해 허용 범위를 좁힌다. 추론을 다룬 편에서 "light" | "dark" 같은 명시가 추론을 좁힌다고 한 줄로 적었다. 여기서 "light""dark"는 문자열 값이면서 타입 표기에 들어가는 리터럴이다.

ts
// file: string-literal-union.ts
export {};
 
type ThemeName = "light" | "dark";
 
function setThemeLiteral(themeValue: ThemeName): string {
  return `theme:${themeValue}`;
}
 
const selectedTheme = setThemeLiteral("dark");
 
// setThemeLiteral("dim");
ts
// 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" 안에는 없다. 리터럴 유니온은 허용할 값을 정확히 나열하는 타입 표기다.

light 와 dark 문자열만 통과하고 dim 문자열은 타입 검사에서 멈추는 분기 일러스트

숫자와 boolean 리터럴도 같은 규칙이다

문자열만 특별한 예외가 아니다. 숫자 리터럴도 타입 표기에 그대로 들어간다. 주사위 값은 좋은 예다. 가능한 값이 1부터 6까지라면 숫자 전체를 받을 필요가 없다.

ts
// 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;
ts
// 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)은 멈춘다. 진단은 숫자 리터럴에도 같은 방식으로 나온다.

txt
Argument of type '7' is not assignable to parameter of type 'DiceRoll'.
txt
Argument of type '7' is not assignable to parameter of type 'DiceRoll'.

truefalse도 리터럴 타입이다. type StrictTrue = truetrue만 허용한다. true | falseboolean과 같은 값 집합을 만든다. 그래서 boolean은 넓은 표기이고, truefalse는 좁은 표기다.

주사위 1부터 6까지는 초록색으로 통과하고 7은 붉은 표시로 멈추는 일러스트

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으로 추론한다.

const 객체의 바인딩은 고정되지만 속성 타입은 string 으로 넓어지는 흐름을 보여주는 일러스트

객체와 배열 리터럴 전체를 as const 로 고정한다

as const는 런타임 값을 바꾸지 않는다. 타입 추론에 대한 지시다. 객체 리터럴 전체를 가장 좁은 리터럴과 readonly 속성으로 추론한다. 배열 리터럴은 readonly 튜플로 추론한다.

ts
// 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;
ts
// 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가 된다. 주석을 해제하면 다음 진단이 나온다.

txt
Cannot assign to 'mode' because it is a read-only property.
txt
Cannot assign to 'mode' because it is a read-only property.

배열도 같은 방식이다. tagsNoConststring[]로 추론한다. 새 문자열을 넣을 수 있다. tagsConstreadonly ["primary", "ghost"]로 추론한다. 컴파일러는 길이와 각 원소의 리터럴 값을 타입에 넣는다.

as const 전후로 객체 속성이 string 에서 dark 리터럴과 readonly 로 바뀌는 비교 일러스트

마지막 메모

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

YouTube 영상

채널 보기
투영과 예측, 그리고 선형 결합 | 선형대수학
행렬의 기본 연산 - 행렬 덧셈, 스칼라 곱, 전치 | 선형대수학
우리가 매일 쓰는 맞춤법 검사기와 라우터 속에 숨겨진 알고리즘은? | Trie 자료구조 이야기
AI를 위한 선형대수학 - 소개 | 선형대수학
트라이(Trie) 자료구조: 파이썬으로 삽입(Insert) 연산 구현하기 | Trie 자료구조 이야기
마지막편, 트라이 노드를 50% 이상 줄이는 방법? 압축 트라이 성능 분석 | Trie 자료구조 이야기
직교성과 벡터 투영 | 선형대수학
트라이(Trie)에서 단어를 삭제하는 방법 | Trie 자료구조 이야기