TypeScript 5.x 고급 패턴 실전 가이드: `satisfies`, `using`, Branded Types로 타입 안전성 완성하기
TypeScript 5.x는 타입 시스템을 "검사 도구"에서 "도메인 설계 언어"로 격상시켜 줍니다. 처음 들으면 과장처럼 느껴질 수 있는데, 실제로 코드베이스에 적용해보면 생각이 바뀌게 됩니다. as any로 타입을 뚫거나, 불필요한 타입 단언을 남발하거나, 반대로 타입을 너무 느슨하게 잡아서 런타임 에러를 뒤늦게 발견하는 상황 — 이런 반복되는 페인 포인트들이 5.x의 패턴들로 상당 부분 해소됩니다.
저도 처음 satisfies를 썼을 때 "이걸 왜 이제야 만들었지?"라고 생각했던 기억이 납니다. 그리고 using 키워드로 DB 커넥션 관리 코드를 리팩토링했을 때 try-finally 블록이 한 줄로 줄어드는 걸 보면서 꽤 감탄했습니다. 타입 안전성을 포기하지 않으면서도 코드가 더 읽기 쉬워지는 것이 TypeScript 5.x의 핵심 철학입니다.
이 글은 TypeScript를 6개월 이상 써온 프론트엔드·백엔드 개발자를 대상으로 합니다. satisfies 연산자, using 키워드, Branded Types, Template Literal Types + infer, Discriminated Unions, NoInfer<T> — 실제 코드와 함께 각 패턴이 어떤 문제를 해결하는지 담았습니다.
핵심 개념
개념들이 많아 보이지만 크게 세 가지 목적으로 묶을 수 있습니다.
- 타입 추론 정밀화:
satisfies,const타입 파라미터,NoInfer<T> - 도메인 모델링: Branded Types, Discriminated Unions, Template Literal Types +
infer - 자원 관리:
using/await using
타입 추론 정밀화
satisfies 연산자: 타입 강제 없이 안전성과 추론을 동시에
실무에서 자주 맞닥뜨리는 상황인데, 객체를 특정 타입으로 선언하면 내부 프로퍼티의 구체적인 타입 정보가 사라지는 문제가 있습니다. Record<string, string | number[]>로 선언한 팔레트 객체에서 .red를 꺼내면 string | number[]가 되어버려서 .map()을 바로 쓸 수가 없죠.
satisfies는 이 딜레마를 해결합니다. 값이 특정 타입을 만족하는지 검증하면서도 추론된 리터럴 타입은 그대로 보존합니다.
const palette = {
red: [255, 0, 0],
green: "#00ff00",
} satisfies Record<string, string | number[]>;
// palette.red는 number[]로 추론됨 (string | number[]가 아님)
palette.red.map(v => v * 2); // 타입 안전하게 동작
palette.green.toUpperCase(); // string 메서드 사용 가능
satisfiesvs 타입 선언:const x: Type = value는 변수를Type으로 강제합니다. 반면satisfies는 타입 검증만 하고 추론 결과는 보존합니다. "타입 체크를 통과했지만 내가 아는 타입은 내가 유지한다"는 개념입니다.
const 타입 파라미터: as const 없이 리터럴 타입 보존
function identity<const T>(value: T): T {
return value;
}
// 이전: string[] — 리터럴 정보 소실
// 이후: readonly ["a", "b"] — 정확한 튜플 타입 유지
const result = identity(["a", "b"]);함수 인자마다 as const를 붙이는 건 솔직히 번거롭고 실수하기 쉽습니다. 제네릭 함수를 설계할 때 <const T>를 선언해두면 호출부에서 별도 처리 없이 리터럴 타입이 자동 보존됩니다. ORM의 select() 함수나 라우터 정의 같은 곳에서 특히 효과적입니다.
NoInfer<T>: 의도하지 않은 타입 추론 차단
function createState<T>(initial: T, fallback: NoInfer<T>): T {
return initial ?? fallback;
}
createState("hello", 42); // 에러: number는 string에 할당 불가
createState("hello", "world"); // OKT는 initial에서만 추론되고, fallback은 추론 후보에서 제외됩니다. API를 설계할 때 "이 파라미터는 추론에 참여하지 않고 검증만 받는다"는 의도를 명확히 표현할 수 있습니다.
도메인 모델링
Branded Types: 런타임 비용 없는 도메인 경계
UserId와 OrderId가 둘 다 string이라면 타입 시스템은 이 둘을 구분하지 못합니다. 잘못된 ID를 넘겨도 컴파일 에러가 나지 않죠. Branded Types는 이 문제를 런타임 오버헤드 없이 해결합니다.
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
// 실제 코드에서는 as 직접 캐스팅 대신, 검증 함수를 통해 생성하는 것을 권장합니다
function createUserId(id: string): UserId {
if (!id.startsWith("user-")) throw new Error("Invalid UserId format");
return id as UserId; // 검증 후 캐스팅 — 경계 진입 시에만 허용
}
function getUser(id: UserId) { /* ... */ }
const userId = createUserId("user-1");
const orderId = "order-1" as OrderId; // 예시용 단순 캐스팅 — 실무에서는 위처럼 검증 함수 사용을 권장합니다
// getUser(orderId); // 컴파일 에러! OrderId는 UserId에 할당 불가명목적 타이핑(Nominal Typing): TypeScript는 기본적으로 구조적 타이핑(형태가 같으면 호환)을 따릅니다. Branded Types는 타입에 고유한 "낙인(brand)"을 추가해 구조가 같아도 서로 다른 타입으로 취급하게 만드는 패턴입니다.
__brand 필드는 런타임에 실제로 존재하지 않습니다. 타입 레벨에서만 동작하기 때문에 성능 부담이 전혀 없습니다.
Discriminated Unions + Exhaustive Check: 누락된 케이스를 컴파일에서 잡기
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rect"; width: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle": return Math.PI * shape.radius ** 2;
case "rect": return shape.width * shape.height;
default: {
const _exhaustive: never = shape; // 새 케이스 추가 시 컴파일 에러
throw new Error("Unknown shape");
}
}
}나중에 triangle 케이스를 Shape에 추가하면 default 블록의 never 할당이 실패하면서 컴파일 에러가 납니다. 런타임이 아니라 컴파일 타임에 누락을 잡아주는 거라 팀 규모가 커질수록 효과가 커집니다.
Template Literal Types + infer: 컴파일 타임 문자열 파싱
조건부 타입과 infer가 처음이라면, infer를 "이 자리의 타입을 이 이름으로 캡처한다"고 읽으면 훨씬 이해하기 쉬워집니다.
type ExtractRouteParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractRouteParams<`/${Rest}`>
: T extends `${string}:${infer Param}`
? Param
: never;
type Params = ExtractRouteParams<"/users/:id/posts/:postId">;
// type Params = "id" | "postId"라우트 문자열에서 파라미터 이름을 타입으로 추출하는 예시입니다. tRPC, Hono 같은 라이브러리들이 이 패턴으로 end-to-end 타입 안전성을 구현합니다. TypeScript Playground에서 직접 붙여넣어 실행해보면 "id" | "postId"가 추론되는 걸 확인할 수 있습니다.
자원 관리
using / await using: 자원 관리를 언어 레벨로
TypeScript 5.2부터 도입된 기능입니다. try-finally로 자원을 닫는 패턴을 언어 수준에서 지원합니다. 중요한 건 동기 자원과 비동기 자원을 구분해서 써야 한다는 점입니다.
동기 자원 (파일 핸들, 락 등): using을 사용합니다.
import { openSync, closeSync } from "fs";
function openFileHandle(path: string): Disposable & { fd: number } {
const fd = openSync(path, "r");
return {
fd,
[Symbol.dispose]() { closeSync(fd); }
};
}
{
using handle = openFileHandle("./data.txt");
// ... 파일 작업
} // 블록 종료 시 자동으로 closeSync 호출, 예외 발생 시에도 보장비동기 자원 (DB 커넥션, HTTP 클라이언트 등): await using을 사용합니다.
function getDbConnection(): AsyncDisposable & { query: (sql: string) => Promise<unknown> } {
const conn = db.connect();
return {
query: (sql) => conn.query(sql),
async [Symbol.asyncDispose]() { await conn.close(); }
};
}
async function fetchUser(id: string) {
await using conn = getDbConnection();
return await conn.query(`SELECT * FROM users WHERE id = ?`);
} // 블록 종료 시 await conn.close() 자동 호출
Symbol.disposevsSymbol.asyncDispose:using은 동기 dispose에,await using은 비동기 dispose에 사용합니다. 비동기 자원에using만 쓰면 dispose가 완료되기 전에 스코프를 벗어나므로 주의가 필요합니다.
isolatedDeclarations: 선언 파일의 병렬 생성
모노레포 환경에서 특히 유용한 기능입니다. isolatedDeclarations: true를 활성화하면 TypeScript가 파일별로 독립적으로 .d.ts를 생성할 수 있어 빌드 병렬화가 가능해집니다. 단, 모든 public API에 명시적 반환 타입 선언이 필요해지는 트레이드오프가 있습니다. 장단점 표에서 다시 다루겠지만, 기존 코드베이스에 도입할 때는 점진적 마이그레이션 전략이 필요합니다.
실전 적용
예시 1: 타입 안전한 이벤트 에미터
이 예시는 K extends keyof Events 제네릭 제약으로 이벤트 이름과 페이로드 타입을 연결합니다. 규모 있는 프론트엔드 앱에서 이벤트 버스를 쓸 때 emit("user:loginn", data)처럼 오타가 런타임에서야 발견되는 문제를 많이 겪습니다.
type EventMap = {
"user:login": { userId: string; timestamp: Date };
"user:logout": { userId: string };
"order:created": { orderId: string; amount: number };
};
class TypedEventEmitter<Events extends Record<string, unknown>> {
on<K extends keyof Events>(event: K, handler: (data: Events[K]) => void) {
// 구현 생략
}
emit<K extends keyof Events>(event: K, data: Events[K]) {
// 구현 생략
}
}
const emitter = new TypedEventEmitter<EventMap>();
emitter.on("user:login", ({ userId, timestamp }) => {
// userId: string, timestamp: Date — 완전한 타입 추론
console.log(userId, timestamp);
});
// emitter.emit("user:loginn", { ... }); // 컴파일 에러: 존재하지 않는 이벤트| 포인트 | 설명 |
|---|---|
K extends keyof Events |
이벤트 이름을 키 유니온으로 제한 |
Events[K] |
이벤트 이름에 대응하는 페이로드 타입 자동 추론 |
| 오타 방지 | 잘못된 이벤트명이 컴파일 에러로 즉시 감지됨 |
예시 2: ORM 스타일 select() 빌더
이 예시는 const 타입 파라미터와 커링을 조합합니다. password 같은 민감한 필드를 타입 수준에서 제거하는 패턴으로, 실수로 포함시키는 걸 컴파일 타임에 방지할 수 있습니다.
type Prettify<T> = { [K in keyof T]: T[K] } & {};
type SelectFields<T, K extends keyof T> = Prettify<Pick<T, K>>;
type User = { id: string; name: string; email: string; password: string };
// T를 먼저 명시하고, K는 fields 인자에서 추론하도록 커링
function select<T>() {
return function <const K extends (keyof T)[]>(
fields: K
): (entity: T) => SelectFields<T, K[number]> {
// 런타임 구현에서 타입 시스템의 한계로 불가피하게 사용
return (entity) => Object.fromEntries(fields.map(f => [f, entity[f]])) as any;
};
}
const safeUser = select<User>()(["id", "name", "email"]);
// 반환 타입: { id: string; name: string; email: string }
// password는 타입에서도, 런타임에서도 제외됨const K를 통해 배열이 string[]이 아닌 정확한 튜플 타입으로 추론되기 때문에 K[number]로 필드 유니온을 만들 수 있습니다. Prettify는 Pick<T, K>처럼 복잡하게 표시되던 타입을 펼쳐서 읽기 좋게 만들어주는 보조 타입입니다.
예시 3: using으로 파일 핸들 안전하게 관리
import { openSync, closeSync, readFileSync } from "fs";
function openFile(path: string): Disposable & { read: () => string } {
const fd = openSync(path, "r");
return {
read() { return readFileSync(path, "utf-8"); },
[Symbol.dispose]() { closeSync(fd); }
};
}
function processFile(path: string): number {
using file = openFile(path);
return file.read().split("\n").length;
} // 예외 발생 시에도 fd가 반드시 닫힘try-finally로 직접 관리하던 패턴보다 훨씬 선언적이고 실수할 여지가 적습니다. 비동기 자원(DB 커넥션 등)이라면 Symbol.asyncDispose와 await using으로 동일한 패턴을 적용할 수 있습니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
satisfies |
타입 강제 캐스팅 없이 안전성과 정확한 추론을 동시에 달성 |
const 타입 파라미터 |
as const 반복 제거, 리터럴 정보 손실 방지 |
| Branded Types | 런타임 오버헤드 0, 도메인 경계를 타입으로 명시 |
Template Literal + infer |
문자열 파싱을 런타임 없이 컴파일 타임에 처리 |
using / await using |
자원 누수 방지, try-finally 대체로 코드 간결화 |
NoInfer<T> |
의도치 않은 타입 추론 차단으로 API 설계 정밀도 향상 |
단점 및 주의사항
솔직히 말하면, 실무에서 가장 자주 발목을 잡는 건 학습 곡선보다 using 호환성 문제와 Branded Types의 직렬화 이슈였습니다. 테이블로 정리하면 다음과 같습니다.
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 학습 곡선 | Template Literal + infer 조합이 팀 내 이해 격차를 유발 |
주석 또는 명확한 타입 별칭으로 의도를 표현 |
| 타입 체조 과용 | 복잡한 조건부 타입이 컴파일 속도 저하 및 IDE 응답성 감소 | 과도한 추상화 대신 명시적 타입 선언 혼용 |
using 호환성 |
기존 라이브러리가 Symbol.dispose를 미구현 |
래퍼 객체로 Symbol.dispose를 직접 구현 |
| Branded Types 직렬화 | JSON 역직렬화 시 브랜드 정보 소실 | 경계에서 재검증 함수(assertion function) 사용 |
isolatedDeclarations |
모든 public API에 명시적 반환 타입 필요 → 코드량 증가 | 점진적 마이그레이션, CI에서 단계별 적용 |
타입 체조(Type Gymnastics): Conditional Types, Mapped Types, Template Literal 등을 과도하게 중첩하여 복잡한 타입 변환을 수행하는 행위를 가리키는 커뮤니티 용어입니다. 영리해 보이지만 유지보수성을 해칩니다.
실무에서 가장 흔한 실수
-
satisfies와 타입 선언의 사용 위치를 혼동하는 것 — 외부로 내보내는 API 경계에는 명시적 타입 선언을, 내부 구현에는satisfies를 적용하는 것이 대체로 적합합니다. -
Branded Types를 JSON 경계에서 재검증 없이 사용하는 것 —
JSON.parse로 받은 값을as UserId로 캐스팅만 하면 브랜드가 보장되지 않습니다. 파싱 직후 검증 함수를 통과시키는 것을 권장합니다. -
비동기 자원에
using을await using없이 사용하는 것 — 비동기 dispose가 필요한 자원에using만 쓰면 dispose가 완료되기 전에 스코프를 벗어납니다.Symbol.asyncDispose와await using을 함께 사용하는 것이 올바른 방법입니다.
마치며
TypeScript 5.x의 고급 패턴들은 타입 시스템을 "검사 도구"에서 "도메인 설계 언어"로 격상시켜 줍니다. 당장 모든 패턴을 적용하기보다는 현재 코드베이스에서 가장 자주 발생하는 문제에 하나씩 도입해보시면 효과를 빠르게 체감할 수 있습니다.
지금 바로 시작해볼 수 있는 3단계:
-
satisfies를 config 객체나 상수 정의에 적용해 보기 →as const를 쓰던 자리와 바꿔보며 차이를 체감해 보시면 좋습니다.tsconfig.json의"strict": true가 활성화되어 있는지 먼저 확인하는 것을 권장합니다. -
도메인 ID 타입(UserId, OrderId 등)에 Branded Types 도입해 보기 →
type Brand<T, B extends string> = T & { readonly __brand: B }한 줄을 유틸리티 파일에 추가하고, 혼용 사고가 가장 잦은 ID 타입 두 개에 먼저 적용해 보시면 좋습니다. 단, 검증 함수를 통해 생성하는 습관을 함께 들이는 것을 권장합니다. -
try-finally로 관리하던 자원 중 하나를using으로 교체해 보기 → TypeScript 5.2 이상,tsconfig의"lib"배열에"esnext"또는"esnext.disposable"이 포함되어 있는지 확인 후 적용해 보시면 됩니다. 비동기 자원이라면await using을 사용하는 것을 함께 기억해두시면 좋습니다.
다음 글: tRPC와 Hono에서 Template Literal Types가 end-to-end 타입 안전성을 어떻게 구현하는지, 내부 동작 원리와 함께 살펴볼 예정입니다.
참고 자료
- TypeScript 5.0 공식 릴리스 노트 | Microsoft
- TypeScript 5.2 공식 릴리스 노트 (
using/await using) | Microsoft - TypeScript 5.4 공식 릴리스 노트 (
NoInfer) | Microsoft - A 10x Faster TypeScript (Go 포팅 공식 발표) | Microsoft DevBlog
- Advanced TypeScript Patterns: Branded Types, Discriminated Unions | DEV Community
- Understanding Branded Types in TypeScript | Learning TypeScript
- TypeScript moduleResolution: bundler 공식 문서 | TypeScript