7.4 TypeScript 5.x 신기능
TypeScript는 매 마이너 버전마다 타입 시스템을 강화하는 새로운 기능을 추가합니다. TypeScript 5.0부터 5.x까지는 특히 타입 추론의 정확성과 런타임 안전성을 크게 개선했습니다. 이 장에서는 실무에서 자주 마주치는 새 기능들을 실제 코드와 함께 살펴봅니다.
const 타입 파라미터 (TS 5.0)
기존에는 제네릭 함수가 리터럴 타입 대신 넓은 타입으로 추론하는 문제가 있었습니다. TypeScript 5.0에서 도입된 const 타입 파라미터(<const T>)는 타입 파라미터에 as const를 자동으로 적용합니다.
// TS 5.0 이전: 리터럴 타입이 소실됨
function createRoute<T extends string>(path: T) {
return { path };
}
const r1 = createRoute("/users");
type R1Path = typeof r1.path; // string (리터럴이 아님!)
// TS 5.0: const 타입 파라미터로 리터럴 보존
function createRouteConst<const T extends string>(path: T) {
return { path };
}
const r2 = createRouteConst("/users");
type R2Path = typeof r2.path; // "/users" (리터럴!)
// 배열에도 적용
function makeArray<const T>(items: T[]) {
return items;
}
const arr1 = makeArray(["a", "b", "c"]);
type Arr1Type = typeof arr1; // ("a" | "b" | "c")[] — TS 5.0 이전
// const arr1 = makeArray(["a", "b", "c"]);
// 실제 TS 5.0: readonly ["a", "b", "c"] 아니고 string[]이지만 const T는 배열에 다름
// 올바른 const 튜플 추론
function makeTuple<const T extends readonly unknown[]>(items: T): T {
return items;
}
const tup = makeTuple(["hello", 42, true] as const);
type TupType = typeof tup; // readonly ["hello", 42, true]
// 실용 예: 타입 안전한 이벤트 이름 목록
function defineEvents<const T extends string>(events: T[]): T[] {
return events;
}
const EVENTS = defineEvents(["click", "hover", "focus"]);
type EventName = (typeof EVENTS)[number]; // "click" | "hover" | "focus"
// 객체에서도 동작
function createConfig<const T extends object>(config: T): T & { readonly __brand: "config" } {
return { ...config, __brand: "config" } as any;
}
const config = createConfig({ port: 3000, host: "localhost", debug: true });
type ConfigPort = typeof config.port; // 3000 (리터럴)
type ConfigHost = typeof config.host; // "localhost" (리터럴)
Variadic Tuple Types (TS 4.0)
가변 길이 튜플 타입은 TypeScript 4.0에서 도입되었습니다. 스프레드 연산자를 타입 레벨에서 사용해 튜플을 동적으로 합치거나 분리할 수 있습니다.
// 기본 가변 튜플
type Concat<T extends unknown[], U extends unknown[]> = [...T, ...U];
type C1 = Concat<[1, 2], [3, 4]>; // [1, 2, 3, 4]
type C2 = Concat<string[], number[]>; // (string | number)[]
// 튜플 앞/뒤에 요소 추가
type Prepend<T extends unknown[], Item> = [Item, ...T];
type Append<T extends unknown[], Item> = [...T, Item];
type P1 = Prepend<[string, number], boolean>; // [boolean, string, number]
type A1 = Append<[string, number], boolean>; // [string, number, boolean]
// 가변 튜플로 구현하는 강력한 타입들
// curry 함수 타입 (정확한 오버로드 없이)
type Curry<
Params extends unknown[],
Return
> = Params extends [infer First, ...infer Rest]
? Rest extends []
? (arg: First) => Return
: (arg: First) => Curry<Rest, Return>
: Return;
type AddCurry = Curry<[number, number, number], number>;
// (arg: number) => (arg: number) => (arg: number) => number
// 함수 파이프라인 타입
type Pipeline<
Fns extends readonly ((...args: any[]) => any)[]
> = Fns extends readonly [infer F, ...infer Rest extends ((...args: any[]) => any)[]]
? F extends (...args: any[]) => infer R
? Rest extends []
? F
: (
...args: Parameters<F>
) => ReturnType<Last<Rest>>
: never
: never;
type Last<T extends any[]> = T extends [...any[], infer L] ? L : never;
// 실용 예: 타입 안전한 함수 파이프
function createPipe<
const Fns extends readonly [
(...args: any[]) => any,
...Array<(arg: any) => any>
]
>(...fns: Fns) {
return (input: Parameters<Fns[0]>[0]) =>
fns.reduce((acc, fn) => fn(acc), input);
}
const pipeline = createPipe(
(n: number) => n * 2,
(n: number) => n.toString(),
(s: string) => s.padStart(5, "0")
);
const result = pipeline(42); // "00084"
type PipeResult = typeof result; // string
// 미들웨어 스택 타입 (Express 스타일)
type MiddlewareFn<Ctx> = (ctx: Ctx, next: () => Promise<void>) => Promise<void>;
type MiddlewareStack<
Middlewares extends MiddlewareFn<any>[]
> = Middlewares extends [MiddlewareFn<infer Ctx>, ...any[]]
? Ctx
: never;
NoInfer — 타입 추론 방지 (TS 5.4)
TypeScript 5.4에서 도입된 NoInfer<T>는 특정 위치에서 타입 추론이 일어나지 않도록 합니다. 타입 파라미터의 추론 위치를 제어해 의도치 않은 타입 확장을 방지합니다.
// 문제: 타입 추론이 defaultValue에서도 일어남
function createValue<T>(value: T, defaultValue: T): T {
return value ?? defaultValue;
}
// "hello"와 0이 있으면 T가 string | number로 추론됨
const v1 = createValue("hello", 0);
// T = string | number (원치 않음!)
// NoInfer로 해결: defaultValue에서는 추론하지 않음
function createValueSafe<T>(value: T, defaultValue: NoInfer<T>): T {
return value ?? defaultValue;
}
// value에서만 T를 추론 → T = string → 0은 string이 아니므로 에러
// const v2 = createValueSafe("hello", 0); // Error!
const v3 = createValueSafe("hello", "world"); // OK, T = string
// 실용 예 1: 이벤트 리스너 등록
function addEventListener<const Events extends string>(
target: EventTarget,
events: Events[],
handler: (event: NoInfer<Events>) => void
): void {
events.forEach(e => target.addEventListener(e, handler as any));
}
// events에서 "click" | "focus"로 추론, handler의 event는 같은 타입이어야 함
addEventListener(document, ["click", "focus"], (event) => {
// event: "click" | "focus"
console.log(event);
});
// 실용 예 2: 상태 머신 전환
function createStateMachine<const State extends string>(
initial: State,
transitions: Record<State, NoInfer<State>[]>
): { state: State; transition: (to: NoInfer<State>) => void } {
let state = initial;
return {
get state() { return state; },
transition(to: State) {
if (transitions[state].includes(to)) {
state = to;
}
},
};
}
const machine = createStateMachine("idle", {
idle: ["loading"],
loading: ["success", "error"],
success: ["idle"],
error: ["idle"],
});
// State가 "idle" | "loading" | "success" | "error"로 추론됨
// transitions의 값도 같은 타입 강제
// 실용 예 3: 타입 안전한 CSS 변수
function getCssVar<const Name extends string>(
name: Name,
fallback: NoInfer<string>
): string {
return getComputedStyle(document.documentElement)
.getPropertyValue(`--${name}`) || fallback;
}
const color = getCssVar("primary-color", "#007bff");
// name에서 "primary-color"로 추론, fallback은 독립적으로 string
using 키워드 (TS 5.2) — 명시적 리소스 관리
TypeScript 5.2는 ECMAScript의 명시적 리소스 관리 제안(using, await using)을 지원합니다. Symbol.dispose / Symbol.asyncDispose를 구현한 객체는 스코프를 벗어날 때 자동으로 해제됩니다.
// Symbol.dispose를 구현하는 기본 패턴
class DatabaseConnection {
private connected = false;
connect(dsn: string): void {
console.log(`Connecting to ${dsn}`);
this.connected = true;
}
query(sql: string): Promise<any[]> {
if (!this.connected) throw new Error("Not connected");
return Promise.resolve([]);
}
[Symbol.dispose](): void {
if (this.connected) {
console.log("Closing database connection");
this.connected = false;
}
}
}
// using으로 자동 해제
function processOrders(): void {
using conn = new DatabaseConnection();
conn.connect("postgresql://localhost:5432/orders");
// conn을 사용한 작업...
// 이 블록을 벗어나면 conn[Symbol.dispose]()가 자동 호출됨
}
// 함수 종료 시 conn.close() 자동 호출 — try/finally 불필요!
// 비동기 리소스: Symbol.asyncDispose
class AsyncFileStream {
private stream: { write: (data: string) => Promise<void>; close: () => Promise<void> } | null = null;
async open(path: string): Promise<void> {
console.log(`Opening file: ${path}`);
this.stream = {
write: async (data) => { console.log(`Writing: ${data}`); },
close: async () => { console.log("File closed"); },
};
}
async write(data: string): Promise<void> {
await this.stream?.write(data);
}
async [Symbol.asyncDispose](): Promise<void> {
await this.stream?.close();
this.stream = null;
}
}
async function writeReport(): Promise<void> {
await using fileStream = new AsyncFileStream();
await fileStream.open("/reports/summary.txt");
await fileStream.write("Report content here");
// 블록 종료 시 await fileStream[Symbol.asyncDispose]() 자동 호출
}
// DisposableStack: 여러 리소스를 묶어서 관리
function processWithMultipleResources(): void {
using stack = new DisposableStack();
const conn1 = stack.use(new DatabaseConnection());
conn1.connect("db1");
const conn2 = stack.use(new DatabaseConnection());
conn2.connect("db2");
// stack이 dispose될 때 conn2, conn1 순서로 자동 해제
}
// 실용 예: 락(Lock) 자동 해제
class Mutex {
private locked = false;
private queue: (() => void)[] = [];
async acquire(): Promise<Disposable> {
while (this.locked) {
await new Promise<void>(resolve => this.queue.push(resolve));
}
this.locked = true;
return {
[Symbol.dispose]: () => {
this.locked = false;
const next = this.queue.shift();
next?.();
},
};
}
}
const mutex = new Mutex();
async function criticalSection(): Promise<void> {
using _lock = await mutex.acquire();
// 락 보유 상태에서 작업
console.log("In critical section");
// 블록 종료 시 자동 락 해제
}
override 키워드
TypeScript 4.3에서 도입된 override 키워드는 자식 클래스에서 부모 클래스의 메서드를 재정의할 때 명시적으로 표시합니다. noImplicitOverride 컴파일러 옵션과 함께 사용합니다.
// tsconfig.json: { "noImplicitOverride": true }
class Animal {
name: string;
constructor(name: string) {
this.name = name;
}
speak(): string {
return `${this.name} makes a sound.`;
}
move(distance: number): string {
return `${this.name} moved ${distance}m.`;
}
}
class Dog extends Animal {
// override 키워드 필수 (noImplicitOverride: true)
override speak(): string {
return `${this.name} barks.`;
}
// 부모에 없는 메서드는 override 없이
fetch(): string {
return `${this.name} fetches the ball!`;
}
}
// 부모 메서드가 삭제되었을 때 안전망
class Cat extends Animal {
override speak(): string {
return `${this.name} meows.`;
}
// override move(distance: number): string {
// 부모에 move가 있으므로 OK, 없으면 컴파일 에러
// }
}
// 추상 클래스와 override
abstract class Shape {
abstract area(): number;
abstract perimeter(): number;
describe(): string {
return `Area: ${this.area()}, Perimeter: ${this.perimeter()}`;
}
}
class Circle extends Shape {
constructor(private radius: number) {
super();
}
override area(): number {
return Math.PI * this.radius ** 2;
}
override perimeter(): number {
return 2 * Math.PI * this.radius;
}
// describe()는 재정의하지 않음 — 부모 구현 사용
}
접근 제어자 in 생성자 파라미터
생성자 파라미터에 접근 제어자를 지정하면 자동으로 프로퍼티 선언과 할당이 이루어집니다.
// 기존 방식 (장황함)
class OldUser {
public readonly id: string;
public name: string;
protected email: string;
private passwordHash: string;
constructor(
id: string,
name: string,
email: string,
passwordHash: string
) {
this.id = id;
this.name = name;
this.email = email;
this.passwordHash = passwordHash;
}
}
// 간결한 방식 (접근 제어자 in 생성자)
class User {
constructor(
public readonly id: string,
public name: string,
protected email: string,
private passwordHash: string
) {}
}
// 상속과 함께 (TS 5.x 개선)
class AdminUser extends User {
constructor(
id: string,
name: string,
email: string,
passwordHash: string,
public readonly permissions: string[]
) {
super(id, name, email, passwordHash);
}
}
// 제네릭 클래스와 결합
class Repository<T extends { id: string }> {
private items: Map<string, T> = new Map();
constructor(
private readonly tableName: string,
private readonly validator: (item: unknown) => item is T
) {}
save(item: T): void {
if (this.validator(item)) {
this.items.set(item.id, item);
}
}
findById(id: string): T | undefined {
return this.items.get(id);
}
}
Type Stripping (Node.js 22.6+, TS 5.x)
TypeScript 5.x와 Node.js 22.6+의 네이티브 TypeScript 지원은 빌드 단계 없이 .ts 파일을 직접 실행할 수 있게 합니다. 타입 구문만 제거(strip)하고 나머지는 그대로 실행합니다.
// server.ts — 빌드 없이 node --experimental-strip-types server.ts 로 실행 가능
// 타입 어노테이션은 런타임에 제거됨
const PORT: number = 3000;
const HOST: string = "localhost";
interface ServerOptions {
port: number;
host: string;
debug?: boolean;
}
function createServer(options: ServerOptions): void {
const { port, host, debug = false } = options;
console.log(`Server running at http://${host}:${port}`);
if (debug) console.log("Debug mode enabled");
}
createServer({ port: PORT, host: HOST, debug: true });
// 주의: Type Stripping은 타입만 제거
// 런타임 변환이 필요한 기능은 지원 안 됨:
// - enum (런타임 객체 생성 필요)
// - namespace (런타임 객체 생성 필요)
// - 데코레이터 (메타데이터 관련 일부)
// 지원되는 것들:
// - 타입 어노테이션
// - interface, type alias
// - as/satisfies 표현식 (타입 부분만 제거)
// - generic syntax
// - readonly, optional 등 타입 한정자
// tsconfig 설정 (type stripping용)
// {
// "compilerOptions": {
// "verbatimModuleSyntax": true, // import type 명시 필요
// "erasableSyntaxOnly": true // 런타임 변환 필요 문법 금지
// }
// }
실전 예제 1: const 파라미터로 정확한 타입 추론
// API 엔드포인트 빌더
interface Endpoint<
Method extends string,
Path extends string,
Params extends Record<string, unknown> = {}
> {
method: Method;
path: Path;
params: Params;
call: (params: Params) => Promise<unknown>;
}
function defineEndpoint<
const Method extends "GET" | "POST" | "PUT" | "DELETE",
const Path extends string,
Params extends Record<string, unknown> = {}
>(
method: Method,
path: Path,
handler: (params: Params) => Promise<unknown>
): Endpoint<Method, Path, Params> {
return {
method,
path,
params: {} as Params,
call: handler,
};
}
const getUserEndpoint = defineEndpoint(
"GET",
"/api/users/:id",
async (params: { id: string }) => {
return { id: params.id, name: "Alice" };
}
);
type GetUserMethod = typeof getUserEndpoint.method; // "GET" (리터럴)
type GetUserPath = typeof getUserEndpoint.path; // "/api/users/:id" (리터럴)
// 라우터 빌딩
const ENDPOINTS = [
defineEndpoint("GET", "/api/users", async () => []),
defineEndpoint("POST", "/api/users", async (p: { name: string; email: string }) => p),
defineEndpoint("GET", "/api/users/:id", async (p: { id: string }) => p),
defineEndpoint("DELETE", "/api/users/:id", async (p: { id: string }) => ({ deleted: p.id })),
] as const;
type EndpointMethod = (typeof ENDPOINTS)[number]["method"];
// "GET" | "POST" | "DELETE"
type EndpointPath = (typeof ENDPOINTS)[number]["path"];
// "/api/users" | "/api/users/:id"
실전 예제 2: using으로 DB 연결 자동 해제
// 프로덕션 수준의 DB 연결 관리
interface QueryResult<T> {
rows: T[];
rowCount: number;
duration: number;
}
class PostgresConnection {
private client: any = null;
private transactionDepth = 0;
constructor(private readonly connectionString: string) {}
async connect(): Promise<void> {
console.log(`Connecting to: ${this.connectionString}`);
// 실제로는 pg.Client 연결
this.client = { query: async (sql: string, params?: any[]) => ({ rows: [], rowCount: 0 }) };
}
async query<T>(sql: string, params?: any[]): Promise<QueryResult<T>> {
if (!this.client) throw new Error("Not connected");
const start = Date.now();
const result = await this.client.query(sql, params);
return { ...result, duration: Date.now() - start };
}
async beginTransaction(): Promise<void> {
this.transactionDepth++;
await this.query("BEGIN");
}
async commit(): Promise<void> {
await this.query("COMMIT");
this.transactionDepth--;
}
async rollback(): Promise<void> {
await this.query("ROLLBACK");
this.transactionDepth--;
}
async [Symbol.asyncDispose](): Promise<void> {
if (this.transactionDepth > 0) {
await this.rollback();
console.warn("Auto-rolled back uncommitted transaction");
}
if (this.client) {
console.log("Closing PostgreSQL connection");
this.client = null;
}
}
}
// 연결 풀 관리자
class ConnectionPool {
private pool: PostgresConnection[] = [];
private readonly maxSize: number;
constructor(
private readonly connectionString: string,
maxSize = 10
) {
this.maxSize = maxSize;
}
async acquire(): Promise<PostgresConnection & AsyncDisposable> {
const conn = new PostgresConnection(this.connectionString);
await conn.connect();
this.pool.push(conn);
return conn;
}
}
// 실제 사용 예
const pool = new ConnectionPool("postgresql://localhost:5432/myapp");
async function transferFunds(
fromId: string,
toId: string,
amount: number
): Promise<void> {
await using conn = await pool.acquire();
try {
await conn.beginTransaction();
await conn.query(
"UPDATE accounts SET balance = balance - $1 WHERE id = $2",
[amount, fromId]
);
await conn.query(
"UPDATE accounts SET balance = balance + $1 WHERE id = $2",
[amount, toId]
);
await conn.commit();
} catch (error) {
await conn.rollback();
throw error;
}
// 함수 종료 시 conn[Symbol.asyncDispose] 자동 호출
// 커밋/롤백이 안 된 트랜잭션 있으면 자동 롤백 후 연결 해제
}
고수 팁: TS 버전별 주요 기능 타임라인
| 버전 | 주요 기능 | 핵심 키워드 |
|---|---|---|
| TS 4.0 | Variadic Tuple Types | [...T, ...U] |
| TS 4.1 | Template Literal Types | `${T}` |
| TS 4.2 | Abstract Construct Signatures | abstract new() |
| TS 4.3 | override 키워드 | override |
| TS 4.4 | Control Flow 개선 | const 조건 좁히기 |
| TS 4.5 | Awaited, tail recursion | Awaited<T> |
| TS 4.7 | infer extends | infer R extends string |
| TS 4.8 | 교차 타입 단순화 | {} 교차 개선 |
| TS 4.9 | satisfies 연산자 | satisfies |
| TS 5.0 | const 타입 파라미터, enum 개선 | <const T> |
| TS 5.1 | Setter/Getter 타입 독립 | getter/setter 불일치 허용 |
| TS 5.2 | using, Disposable | using, Symbol.dispose |
| TS 5.3 | import attributes 개선 | with { type: "json" } |
| TS 5.4 | NoInfer, preserved narrowing | NoInfer<T> |
| TS 5.5 | Inferred Type Predicates | 자동 타입 가드 추론 |
| TS 5.6 | Iterator Helper types | Iterator<T> |
| TS 5.7 | Path rewriting, checks | --rewriteRelativeImportExtensions |
// TS 5.5 inferred type predicates (자동 타입 가드 추론) 예시
const values = [1, null, "hello", undefined, 2, "world"] as const;
// TS 5.5 이전: 명시적 타입 가드 필요
function isString(v: unknown): v is string {
return typeof v === "string";
}
// TS 5.5 이후: filter 결과의 타입이 자동 추론됨
const strings = values.filter(v => typeof v === "string");
// 타입: ("hello" | "world")[] ← 자동으로 null, undefined, number 제거
const numbers = values.filter(v => typeof v === "number");
// 타입: (1 | 2)[]
// 복잡한 조건도 자동 추론
interface ApiResponse {
status: "success" | "error";
data?: unknown;
error?: string;
}
const responses: ApiResponse[] = [
{ status: "success", data: { id: 1 } },
{ status: "error", error: "Not found" },
{ status: "success", data: { id: 2 } },
];
const successResponses = responses.filter(r => r.status === "success" && r.data != null);
// 타입: { status: "success" | "error"; data?: unknown; error?: string; }[]
// (완전히 좁혀지진 않지만, 조건이 반영됨)
정리
| 기능 | 버전 | 핵심 이점 | 사용 시나리오 |
|---|---|---|---|
<const T> | TS 5.0 | 리터럴 타입 자동 보존 | 팩토리 함수, 설정 빌더 |
| Variadic Tuples | TS 4.0 | 동적 튜플 조작 | 커링, 파이프라인 |
NoInfer<T> | TS 5.4 | 추론 위치 제어 | 기본값, 이벤트 리스너 |
using | TS 5.2 | 자동 리소스 해제 | DB 연결, 파일, 락 |
override | TS 4.3 | 오버라이드 안전성 | 클래스 상속 |
| 생성자 접근 제어자 | TS 초기 | 보일러플레이트 제거 | 도메인 모델 클래스 |
| Type Stripping | Node 22.6+ | 빌드 없이 TS 실행 | 개발 스크립트, 도구 |
다음 장에서는 DeepPartial, DeepReadonly, Brand 타입 등 커스텀 유틸리티 타입을 직접 구현하는 방법을 배웁니다. 실무에서 반복되는 타입 패턴을 라이브러리화하는 전략도 살펴봅니다.