🔥 값 레벨에서 타입 레벨까지

TypeScript 는 무엇인가
TypeScript 를 처음 만졌을 때 가장 먼저 본 건 에디터의 빨간 줄이었다. add(3, "4") 처럼 string 을 number 매개변수에 넣자 Argument of type 'string' is not assignable to parameter of type 'number' 라는 에러가 떴고, 뜻은 'number 매개변수에 string 타입 값을 넣을 수 없다' 였다. JavaScript 라면 실행해야 알았을 실수가 코드를 적는 시점에 멈췄다.
TypeScript 는 JavaScript 위에 타입 검사 한 겹을 더 얹는 언어다. 변수와 매개변수에 타입을 달면, 컴파일 시점에 컴파일러가 그 타입의 일관성을 검사한다.
// file: typed.ts
export {};
function add(a: number, b: number): number {
return a + b;
}
console.log(add(3, 4)); // 7
// 아래 줄의 주석을 풀면 TS2345 컴파일 에러:
// console.log(add("3", 4));
// Argument of type 'string' is not assignable to parameter of type 'number'.// file: typed.ts
export {};
function add(a: number, b: number): number {
return a + b;
}
console.log(add(3, 4)); // 7
// 아래 줄의 주석을 풀면 TS2345 컴파일 에러:
// console.log(add("3", 4));
// Argument of type 'string' is not assignable to parameter of type 'number'.이런 컴파일 시점 안전망 때문에 새 프로젝트는 대부분 TypeScript 로 시작한다. 기존 JavaScript 코드베이스도 점진적으로 TypeScript 로 옮겨가는 흐름이다.
기본 사용법까지는 무난하다. 변수에 타입을 달고, 인터페이스를 정의하고, 제네릭 함수를 짜는 단계는 입문 자료가 충분히 안내한다. 진짜 어려운 부분은 다른 곳에 있다.
진짜 어려운 부분
type-fest 나 react-router 같은 인기 라이브러리의 타입 정의를 열면 입문 자료에서 보지 못한 문법이 한꺼번에 등장한다.
// file: deep-readonly.ts
export {};
// 깊이 모든 속성을 readonly 로 만드는 유틸리티 타입
type DeepReadonly<T> = T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
type Config = {
server: { port: number; host: string };
features: { auth: boolean };
};
type FrozenConfig = DeepReadonly<Config>;
// 결과: 모든 중첩 속성이 readonly 로 마킹됨// file: deep-readonly.ts
export {};
// 깊이 모든 속성을 readonly 로 만드는 유틸리티 타입
type DeepReadonly<T> = T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
type Config = {
server: { port: number; host: string };
features: { auth: boolean };
};
type FrozenConfig = DeepReadonly<Config>;
// 결과: 모든 중첩 속성이 readonly 로 마킹됨extends, 매핑 타입, 재귀가 한 줄에 같이 들어 있어서 처음 읽으면 왜 이렇게 적는지 바로 설명하기 어렵다. 그래서 바로 다음 예시까지 이어서 봐야 공통 규칙을 분해할 수 있다.
react-router 처럼 라우트 문자열에서 파라미터 키를 추출하는 라이브러리는 한 단계 더 어려운 타입을 짠다.
// file: route-params.ts
export {};
// "/users/:id/posts/:postId" 같은 라우트에서 파라미터 키를 추출하는 타입
type RouteParams<S extends string> =
S extends `${string}:${infer Param}/${infer Rest}`
? { [K in Param]: string } & RouteParams<Rest>
: S extends `${string}:${infer Param}`
? { [K in Param]: string }
: {};
type Params = RouteParams<"/users/:id/posts/:postId">;
// 결과: { id: string } & { postId: string }// file: route-params.ts
export {};
// "/users/:id/posts/:postId" 같은 라우트에서 파라미터 키를 추출하는 타입
type RouteParams<S extends string> =
S extends `${string}:${infer Param}/${infer Rest}`
? { [K in Param]: string } & RouteParams<Rest>
: S extends `${string}:${infer Param}`
? { [K in Param]: string }
: {};
type Params = RouteParams<"/users/:id/posts/:postId">;
// 결과: { id: string } & { postId: string }infer, 템플릿 리터럴 타입, 분배 조건부, 재귀가 함께 들어 있으니 type-fest 와 react-router 같은 라이브러리들은 타입 레벨에서도 작은 프로그램 을 짠다. 그래서 TypeScript 의 진짜 어려움은 타입 시스템 자체를 하나의 작은 언어로 읽어야 한다는 데 있다.

두 레벨을 한 흐름으로
값 레벨에서 타입 레벨까지, 타입 시스템을 하나의 언어로 다루는 단계까지 한 흐름으로 짠다.
값 레벨에서는 타입 주석, 추론, 객체와 인터페이스, 유니온과 좁히기, 함수와 클래스, 제네릭, 유틸리티 타입을 다룬다. 실무 코드에서 매일 쓰는 문법과 기능이다.
타입 레벨에서는 조건부 타입, 매핑 타입, 템플릿 리터럴 타입, 재귀, infer 의 패턴 매칭을 다룬다. 그리고 그것을 도메인 모델링과 라이브러리 코드 분석에 적용한다. 위에서 본 DeepReadonly 와 RouteParams 같은 코드가 어떻게 동작하는지 직접 분해할 수 있는 단계까지 간다.
같은 개념도 단계마다 맡는 역할이 다르다. 제네릭은 처음에는 함수 추상화 도구로 다루고, 뒤에서는 타입을 계산하는 도구로 다시 쓴다.

TS 6.0 이 기준
이 글은 TypeScript 6.0 을 기준으로 적는다. 2026 년 3 월에 나온 버전이고, 본문 코드는 모두 6.0 에서 그대로 동작한다. 5.x 와 비교하면 기본값 몇 개가 달라져서 설정 설명도 그 기준에 맞춰 읽어야 한다.
strict: true가 기본이다. 명시할 필요가 없다.target: "es2025"가 권장이다. ES5 타깃은 deprecate 됐다.module: "nodenext"+moduleResolution: "nodenext"가 표준 조합이다.verbatimModuleSyntax: true로 컴파일러가import type와 일반import를 분명히 구분하도록 강제한다.- TypeScript 6.0 에서는
--outFile,--baseUrl같은 레거시 옵션을 더 이상 쓰지 않는다.
라이브러리 코드 안에는 입문 자료에서 보지 못한 타입 레벨 프로그래밍이 들어 있다. 타입 레벨까지 가려면 값 레벨부터 차근차근 짚어야 한다. 값 레벨에서 익힌 제네릭 같은 도구를 타입 레벨에서도 그대로 다시 쓴다.










