🔥 값 레벨과 타입 레벨
강의 목차

처음 TypeScript 코드를 읽을 때 나는 한 화면에서 두 가지 정보를 같이 읽었다. 같은 함수 옆에 (a: number, b: number) 같은 표시가 붙어 있었지만, 실행 결과는 그 표시를 전혀 따라오지 않았다. 어떤 줄은 실제로 동작하고 어떤 줄은 검사 자료로만 남는다는 구분이 한참 동안 또렷하게 잡히지 않았다.
내가 그 차이를 또렷하게 이해한 때는 값 레벨 과 타입 레벨 이라는 말을 노트에 적고, 각 줄을 두 묶음으로 나눴을 때였다. 처음에는 함수와 변수에 붙는 타입 주석부터 본다. 그다음에는 타입 시스템 자체를 함수 호출처럼 읽는 단계로 이어 간다.
값 레벨에서 실행되는 코드
값 레벨은 단순하다. 런타임에 실제로 동작하는 모든 줄이 값 레벨에 속한다. 변수에 값이 담기고, 함수가 매개변수를 받아 결과를 돌려주고, 콘솔이 무언가를 출력하는 일은 전부 값 레벨에서 일어난다. JavaScript 가 평소에 하던 일과 같다.
return [a, b] as const 가 실제 배열 값을 만든다. 매개변수와 반환 타입에 붙은 주석은 그 값을 설명할 뿐이다. 두 값을 배열로 묶어 돌려주는 일은 런타임에서 return [a, b] as const 가 한다.
// file: pair-value.ts
export {};
function pair<A, B>(a: A, b: B): readonly [A, B] {
return [a, b] as const;
}
const p = pair(3, "three");
console.log(p); // [ 3, 'three' ]// file: pair-value.ts
export {};
function pair<A, B>(a: A, b: B): readonly [A, B] {
return [a, b] as const;
}
const p = pair(3, "three");
console.log(p); // [ 3, 'three' ]bunx tsx pair-value.ts 로 실행하면 런타임이 [ 3, 'three' ] 를 한 줄 찍는다. <A, B> 와 : readonly [A, B] 는 값을 만드는 코드가 아니다. return [a, b] as const 가 배열 값을 만들고, 나머지 표시는 컴파일 시점에 타입을 검사할 때만 쓴다.

타입 레벨에서만 쓰이는 표현
타입 레벨은 컴파일러가 읽는 타입 표현이다. 같은 생각을 함수 매개변수 주석으로 적을 수도 있고, 타입 별칭으로 따로 적을 수도 있다. Pair 를 타입 별칭으로 적으면 다음과 같다.
// file: pair-type.ts
export {};
type Pair<A, B> = readonly [A, B];
const sample: Pair<number, string> = [3, "three"];
console.log(sample); // [ 3, 'three' ]// file: pair-type.ts
export {};
type Pair<A, B> = readonly [A, B];
const sample: Pair<number, string> = [3, "three"];
console.log(sample); // [ 3, 'three' ]type Pair<A, B> = readonly [A, B] 는 함수 호출과 비슷한 규칙으로 읽으면 된다. A 와 B 를 넣으면 readonly [A, B] 가 나온다. 엄밀한 문법 이름은 generic type alias instantiation 이지만, 지금 단계에서는 입력 타입 두 개와 결과 타입 하나가 있다는 점만 잡으면 충분하다. Pair<number, string> 는 number 와 string 을 넣은 표기고, 결과는 readonly [number, string] 다.
여기서는 단순한 별칭 하나만 본다. 뒤로 가면 DeepReadonly<T> 처럼 같은 규칙을 재귀로 적용하는 타입도 나온다. 그래도 읽는 방법은 같다. 입력 타입을 넣고 결과 타입을 확인하면 된다.

type erasure: 결과 JavaScript 에 남지 않는 타입 주석
값 레벨과 타입 레벨을 가장 분명하게 가르는 사실은 type erasure 다. TypeScript 컴파일러는 .ts 를 .js 로 바꿀 때 타입 주석을 결과 파일에 남기지 않는다. 영어로 erasure 라고 부르고, 한국어로는 보통 컴파일 과정에서 타입 정보를 지운다고 설명한다.
이 짧은 예제는 type erasure 를 바로 보여 준다.
// file: erased.ts
export {};
const greet = (name: string): string => `Hello, ${name}!`;
console.log(greet("타입 레벨"));// file: erased.ts
export {};
const greet = (name: string): string => `Hello, ${name}!`;
console.log(greet("타입 레벨"));이 파일을 컴파일하면 (name: string): string 에 있던 두 개의 : string 표시는 결과 JavaScript 로 넘어가지 않는다. 컴파일 옵션에 따라 출력 JavaScript 의 형태는 달라질 수 있지만, 타입 주석이 남지 않는다 는 사실은 같다. 컴파일 단계에서는 타입 주석을 검사 자료로만 쓰고, 런타임에서는 그 정보를 들고 가지 않는다.
이 한 가지가 타입 코드의 성격을 정한다. 타입 주석과 type alias, 조건부 타입 같은 순수 타입 표현은 런타임 코드 크기를 늘리지 않는다. TypeScript 핸드북도 TypeScript 고유 문법 대부분을 컴파일 단계에서 걷어 낸다고 설명한다. 타입을 복잡하게 써도 그 정보는 결과 JavaScript 에 남지 않는다. 대신 프로그램이 실행된 뒤에는 그 타입 정보를 다시 꺼내 쓸 수 없다.
tsc --noEmit 은 .js 파일을 만들지 않고 검사만 한다. bunx tsx 는 실제 JavaScript 를 실행한다. 그래서 두 도구는 같은 .ts 파일을 보더라도 확인하는 대상이 다르다. 앞에서 본 tsc 와 tsx 의 차이도 바로 여기서 나온다.

한 파일에서 값 레벨과 타입 레벨을 함께 보기
같은 발상이 값 레벨과 타입 레벨에 어떻게 나란히 적힐 수 있는지를 한 파일에 모아 적는다.
// file: value-vs-type.ts
export {};
// 값 레벨 — 런타임에 실제로 동작한다
function pair<A, B>(a: A, b: B): readonly [A, B] {
return [a, b] as const;
}
// 타입 레벨 — 컴파일 시점에만 검사된다
type Pair<A, B> = readonly [A, B];
const valueResult = pair(3, "three");
const typeResult: Pair<number, string> = [3, "three"];
console.log(valueResult);
console.log(typeResult);// file: value-vs-type.ts
export {};
// 값 레벨 — 런타임에 실제로 동작한다
function pair<A, B>(a: A, b: B): readonly [A, B] {
return [a, b] as const;
}
// 타입 레벨 — 컴파일 시점에만 검사된다
type Pair<A, B> = readonly [A, B];
const valueResult = pair(3, "three");
const typeResult: Pair<number, string> = [3, "three"];
console.log(valueResult);
console.log(typeResult);같은 워크스페이스 안에서 두 명령을 차례로 실행한다.
bunx tsc --noEmit
bunx tsx value-vs-type.tsbunx tsc --noEmit
bunx tsx value-vs-type.tstsc --noEmit 은 타입에 문제가 없으면 보통 출력 없이 끝난다. 나는 그 결과를 보고 이 파일에서 pair<A, B> 함수와 Pair<A, B> 타입 별칭이 같은 readonly [A, B] 를 기준으로 잡았다고 확인한다. 이어서 bunx tsx 를 실행하면 런타임이 [ 3, 'three' ] 를 두 줄 찍는다. 런타임에는 타입 별칭이 사라지고 두 배열 값만 남는다.
이 코드를 처음 적었을 때는 valueResult 와 typeResult 가 런타임에서 같은 값을 보여 준다는 점이 어색했다. 두 배열은 서로 다른 인스턴스지만 내용은 같다. 차이는 값을 만드는 방법뿐이다. Pair<number, string> 표기는 컴파일 시점에만 검사 기준으로 남는다.

값 레벨과 타입 레벨을 구분하는 기준
값 레벨 은 런타임에 동작하는 코드다. 타입 레벨 은 컴파일 시점에만 검사되고 결과 JavaScript 에 남지 않는 표현이다. 같은 함수 표기가 두 차원에 함께 적힐 수 있지만, 컴파일러와 런타임은 서로 다른 부분을 본다.
이 기준을 먼저 잡아 두면 뒤에 나오는 내용을 읽을 때 기준점을 잃지 않는다. 값 레벨 쪽에서는 변수, 함수, 인터페이스, 제네릭 함수에 타입을 붙이는 법을 다룬다. 타입 레벨 쪽에서는 Pair 에서 시작해 DeepReadonly 같은 재귀 타입까지 간다. 둘 다 같은 소스 파일에 적히지만 컴파일 뒤에 남는 것은 값 레벨 코드다.
설정 파일을 읽을 때도 같은 구분을 그대로 쓴다. strict 는 타입 검사 쪽 설정이고, target 은 결과 JavaScript 의 기준을 정한다. module 과 verbatimModuleSyntax 는 emit 과 import 처리에 함께 영향을 준다. 각 옵션이 어느 쪽과 더 직접 이어지는지 먼저 나누면 tsconfig.json 에서 봐야 할 포인트를 바로 잡는다.











