🔥 값 레벨과 타입 레벨

1026자
11분

pair.ts 한 파일이 가운데 놓이고 위로는 컴파일 시점에만 사는 타입 레벨, 아래로는 런타임에 동작하는 값 레벨이 갈라져 나오는 표지

처음 TypeScript 코드를 읽을 때 나는 한 화면에서 두 가지 정보를 같이 읽었다. 같은 함수 옆에 (a: number, b: number) 같은 표시가 붙어 있었지만, 실행 결과는 그 표시를 전혀 따라오지 않았다. 어떤 줄은 실제로 동작하고 어떤 줄은 검사 자료로만 남는다는 구분이 한참 동안 또렷하게 잡히지 않았다.

내가 그 차이를 또렷하게 이해한 때는 값 레벨타입 레벨 이라는 말을 노트에 적고, 각 줄을 두 묶음으로 나눴을 때였다. 처음에는 함수와 변수에 붙는 타입 주석부터 본다. 그다음에는 타입 시스템 자체를 함수 호출처럼 읽는 단계로 이어 간다.

값 레벨에서 실행되는 코드

값 레벨은 단순하다. 런타임에 실제로 동작하는 모든 줄이 값 레벨에 속한다. 변수에 값이 담기고, 함수가 매개변수를 받아 결과를 돌려주고, 콘솔이 무언가를 출력하는 일은 전부 값 레벨에서 일어난다. JavaScript 가 평소에 하던 일과 같다.

return [a, b] as const 가 실제 배열 값을 만든다. 매개변수와 반환 타입에 붙은 주석은 그 값을 설명할 뿐이다. 두 값을 배열로 묶어 돌려주는 일은 런타임에서 return [a, b] as const 가 한다.

ts
// 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' ]
ts
// 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-value.ts 가 tsx 명령으로 들어가 터미널에 결과 배열 한 줄을 출력하는 흐름 다이어그램

타입 레벨에서만 쓰이는 표현

타입 레벨은 컴파일러가 읽는 타입 표현이다. 같은 생각을 함수 매개변수 주석으로 적을 수도 있고, 타입 별칭으로 따로 적을 수도 있다. Pair 를 타입 별칭으로 적으면 다음과 같다.

ts
// file: pair-type.ts
export {};
 
type Pair<A, B> = readonly [A, B];
 
const sample: Pair<number, string> = [3, "three"];
console.log(sample);  // [ 3, 'three' ]
ts
// 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] 는 함수 호출과 비슷한 규칙으로 읽으면 된다. AB 를 넣으면 readonly [A, B] 가 나온다. 엄밀한 문법 이름은 generic type alias instantiation 이지만, 지금 단계에서는 입력 타입 두 개와 결과 타입 하나가 있다는 점만 잡으면 충분하다. Pair<number, string>numberstring 을 넣은 표기고, 결과는 readonly [number, string] 다.

여기서는 단순한 별칭 하나만 본다. 뒤로 가면 DeepReadonly<T> 처럼 같은 규칙을 재귀로 적용하는 타입도 나온다. 그래도 읽는 방법은 같다. 입력 타입을 넣고 결과 타입을 확인하면 된다.

A 타입과 B 타입을 인자로 받아 readonly 튜플 타입을 돌려주는 함수 모양으로 그린 type Pair 다이어그램

type erasure: 결과 JavaScript 에 남지 않는 타입 주석

값 레벨과 타입 레벨을 가장 분명하게 가르는 사실은 type erasure 다. TypeScript 컴파일러는 .ts.js 로 바꿀 때 타입 주석을 결과 파일에 남기지 않는다. 영어로 erasure 라고 부르고, 한국어로는 보통 컴파일 과정에서 타입 정보를 지운다고 설명한다.

이 짧은 예제는 type erasure 를 바로 보여 준다.

ts
// file: erased.ts
export {};
 
const greet = (name: string): string => `Hello, ${name}!`;
 
console.log(greet("타입 레벨"));
ts
// 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 의 차이도 바로 여기서 나온다.

erased.ts 의 타입 주석이 tsc 를 통과하면서 erased.js 결과물에서 사라진 모양을 세 단계로 보여 주는 흐름도

한 파일에서 값 레벨과 타입 레벨을 함께 보기

같은 발상이 값 레벨과 타입 레벨에 어떻게 나란히 적힐 수 있는지를 한 파일에 모아 적는다.

ts
// 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);
ts
// 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);

같은 워크스페이스 안에서 두 명령을 차례로 실행한다.

bash
bunx tsc --noEmit
bunx tsx value-vs-type.ts
bash
bunx tsc --noEmit
bunx tsx value-vs-type.ts

tsc --noEmit 은 타입에 문제가 없으면 보통 출력 없이 끝난다. 나는 그 결과를 보고 이 파일에서 pair<A, B> 함수와 Pair<A, B> 타입 별칭이 같은 readonly [A, B] 를 기준으로 잡았다고 확인한다. 이어서 bunx tsx 를 실행하면 런타임이 [ 3, 'three' ] 를 두 줄 찍는다. 런타임에는 타입 별칭이 사라지고 두 배열 값만 남는다.

이 코드를 처음 적었을 때는 valueResulttypeResult 가 런타임에서 같은 값을 보여 준다는 점이 어색했다. 두 배열은 서로 다른 인스턴스지만 내용은 같다. 차이는 값을 만드는 방법뿐이다. Pair<number, string> 표기는 컴파일 시점에만 검사 기준으로 남는다.

값 레벨 pair() 와 타입 레벨 Pair 가 같은 모양을 두 차원에 나란히 두고 비교되는 두 패널 다이어그램

값 레벨과 타입 레벨을 구분하는 기준

값 레벨 은 런타임에 동작하는 코드다. 타입 레벨 은 컴파일 시점에만 검사되고 결과 JavaScript 에 남지 않는 표현이다. 같은 함수 표기가 두 차원에 함께 적힐 수 있지만, 컴파일러와 런타임은 서로 다른 부분을 본다.

이 기준을 먼저 잡아 두면 뒤에 나오는 내용을 읽을 때 기준점을 잃지 않는다. 값 레벨 쪽에서는 변수, 함수, 인터페이스, 제네릭 함수에 타입을 붙이는 법을 다룬다. 타입 레벨 쪽에서는 Pair 에서 시작해 DeepReadonly 같은 재귀 타입까지 간다. 둘 다 같은 소스 파일에 적히지만 컴파일 뒤에 남는 것은 값 레벨 코드다.

설정 파일을 읽을 때도 같은 구분을 그대로 쓴다. strict 는 타입 검사 쪽 설정이고, target 은 결과 JavaScript 의 기준을 정한다. moduleverbatimModuleSyntax 는 emit 과 import 처리에 함께 영향을 준다. 각 옵션이 어느 쪽과 더 직접 이어지는지 먼저 나누면 tsconfig.json 에서 봐야 할 포인트를 바로 잡는다.

컴파일 시점 평면 위에 A, B, Pair 가 흐릿하게 떠 있고 그 아래 런타임 평면에서 3, three, 두 값의 튜플이 또렷하게 이어지며 두 평면이 한 소스 파일을 두 시간선으로 본다는 다이어그램

YouTube 영상

채널 보기
투영과 예측, 그리고 선형 결합 | 선형대수학
행렬의 가장 중요한 연산 - 행렬 곱셈 | 선형대수학
AI는 데이터를 어떻게 분류할까? 벡터의 거리와 KNN 알고리즘 | 선형대수학
스칼라 곱셈과 내적의 기하학적 의미 | 선형대수학
Trie 자료구조 파이썬 구현: Search와 Starts With 연산 | Trie 자료구조 이야기
우리가 매일 쓰는 맞춤법 검사기와 라우터 속에 숨겨진 알고리즘은? | Trie 자료구조 이야기
AI는 왜 수백 차원의 벡터를 사용할까? 고차원 공간과 행렬 | 선형대수학
트라이(Trie)를 이용한 자동 완성 알고리즘 | Trie 자료구조 이야기