- Published on
ES2024 Records & Tuples로 TS 타입추론 극대화
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버-클라이언트 경계가 얇아지고, 상태 관리와 캐시 계층이 복잡해질수록 “값이 안 변한다”는 가정이 무너지기 쉽습니다. 그래서 많은 팀이 as const, Readonly, 불변 업데이트 유틸, 구조적 공유 같은 패턴을 조합해 안정성을 확보합니다. 하지만 이 방식은 두 가지 한계가 있습니다.
- 런타임에서 “진짜 불변”이 아니라 관례에 가깝다는 점
- 값 비교가 기본적으로 참조 동일성에 묶여 있어, 캐시 키/메모이제이션/디듀프에서 실수가 잦다는 점
ES2024 Records & Tuples(현재는 TC39 제안 단계로 알려져 있으며, 엔진/툴체인 지원은 제한적일 수 있음)는 이 지점을 정면으로 다룹니다. Record와 Tuple은 깊은 불변(Deeply Immutable) 과 값 동등성(Deep Value Equality) 을 언어 차원에서 제공하는 것을 목표로 합니다. TypeScript 입장에서는 이 모델이 자리 잡을수록 “리터럴 기반 추론”과 “안전한 API 경계”를 더 자연스럽게 만들 수 있습니다.
이 글은 제안의 핵심 개념을 실무 관점에서 정리하고, TypeScript 타입 추론을 극대화하는 설계 패턴을 코드로 보여줍니다. 또한 현재(2026년 초 기준) 현실적인 도입 전략도 함께 다룹니다.
관련해서 TS에서 타입 오류를 더 일찍 잡는 패턴은 TypeScript 5.x satisfies로 타입오류 조기 차단하기 글도 같이 보면 좋습니다.
Records & Tuples가 바꾸는 전제
1) 깊은 불변(Deep immutability)
as const는 타입 레벨에서만 읽기 전용을 강제합니다. 런타임에서는 여전히 변경 가능하며, Object.freeze를 쓰더라도 깊은 freeze는 별도 구현이 필요합니다.
Record/Tuple은 설계 목표 자체가 “깊은 불변 값”입니다. 즉 중첩된 구조까지 변경이 불가능한 값으로 취급됩니다.
2) 값 동등성(Deep value equality)
기존 JS 객체/배열은 === 비교가 참조 동일성입니다.
Record/Tuple은 같은 구조와 같은 원소를 가지면 “같은 값”으로 비교되는 모델을 지향합니다. 이게 캐시 키, 메모이제이션, dedupe 로직에서 매우 강력합니다.
3) 리터럴 기반 API 설계가 쉬워짐
불변 + 값 동등성이 기본이면, “설정 객체”나 “쿼리 키”를 안전하게 전달하고 재사용하는 패턴이 훨씬 자연스러워집니다. TypeScript는 이런 값이 리터럴로 유지될수록 추론이 강해집니다.
TypeScript에서 기대하는 추론 이득
TypeScript 타입 추론이 강해지는 대표 지점은 아래입니다.
- 리터럴 유지:
"GET"같은 문자열 리터럴이string으로 넓혀지지 않게 유지 - 튜플 인덱스 추론:
tuple[0]이 정확한 타입으로 추론 - 키-값 매핑: 레코드의 키가 유니온으로 유지되면
keyof/매핑 타입이 강력해짐 - 불변 전제: 함수가 입력을 변경하지 않는다는 전제 하에 안전한 캐싱/메모이제이션 시그니처 설계 가능
다만 현실적으로 TS가 Record/Tuple을 네이티브 문법으로 완전 지원하기 전까지는, “동일한 의도”를 타입과 런타임 유틸로 흉내 내는 방식이 필요합니다. 이 글의 코드는 그 중간 단계 전략까지 포함합니다.
실전 1: 쿼리 키를 값으로 만들기 (캐시/메모이제이션)
React Query, SWR, GraphQL 클라이언트 등에서 가장 흔한 버그가 “키가 매번 새로 만들어져 캐시가 안 탄다”입니다. 지금은 보통 배열 키를 쓰고, as const로 고정합니다.
아래는 Tuple이 있다고 가정했을 때 이상적인 형태를 TS로 모델링한 예시입니다. 실제 문법은 엔진/제안에 따라 달라질 수 있으니, 여기서는 의도를 중심으로 보세요.
// 의도: 깊은 불변 + 값 동등성을 갖는 키
// (현 시점에서는 TS/런타임에서 동일 모델을 유틸로 대체)
type QueryKey = readonly unknown[];
function makeQueryKey<const T extends QueryKey>(...parts: T) {
return parts;
}
const key1 = makeQueryKey("user", { id: 1 });
// ^? readonly ["user", { readonly id: 1 }]
const key2 = makeQueryKey("user", { id: 1 });
// 현재 JS에서는 key1 !== key2 (참조 다름)
// Records & Tuples가 제공하려는 모델에서는 값 동등성으로 같게 취급 가능
여기서 핵심은 const 제네릭입니다. makeQueryKey가 리터럴을 최대한 유지해 주면, 아래 같은 함수 시그니처가 가능해집니다.
type Fetcher<K extends QueryKey, R> = (key: K) => Promise<R>;
function createQuery<K extends QueryKey, R>(key: K, fetcher: Fetcher<K, R>) {
return { key, fetcher };
}
const q = createQuery(
makeQueryKey("user", { id: 1 }),
async (key) => {
// key[0]은 "user"로, key[1].id는 1로 리터럴 추론 가능
const [, params] = key;
return { name: "Alice", id: params.id };
}
);
이 패턴은 TS 5.x satisfies로 타입 좁힘이 안될 때 해결법에서 다루는 “타입 유지 vs 타입 검증” 문제와도 연결됩니다.
현실적인 보완: 안정적인 직렬화 키
값 동등성이 엔진에 없으면, 결국 캐시 키는 문자열/해시로 내려갑니다.
function stableStringify(value: unknown): string {
// 매우 단순화된 예시. 실무에서는 순환 참조/정렬/타입 처리 필요
return JSON.stringify(value, (_k, v) => {
if (v && typeof v === "object" && !Array.isArray(v)) {
return Object.keys(v as Record<string, unknown>)
.sort()
.reduce((acc, key) => {
(acc as any)[key] = (v as any)[key];
return acc;
}, {} as Record<string, unknown>);
}
return v;
});
}
function toCacheKey<const K extends QueryKey>(key: K): string {
return stableStringify(key);
}
const cacheKey = toCacheKey(makeQueryKey("user", { id: 1 }));
Records & Tuples가 보편화되면, 이런 “직렬화 기반 키”의 필요성이 줄어들고, 런타임 비용도 낮아질 여지가 있습니다.
실전 2: 설정 객체를 안전한 값으로 고정하기
설정은 보통 “한 번 만들고 여기저기 전달”합니다. 그런데 중간 레이어에서 설정을 살짝 수정해 버리면, 장애가 나도 원인을 찾기 어렵습니다.
Record가 제공하려는 깊은 불변 모델을 TS에서 최대한 흉내 내면 아래처럼 갑니다.
type DeepReadonly<T> =
T extends (...args: any[]) => any ? T :
T extends readonly (infer U)[] ? readonly DeepReadonly<U>[] :
T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } :
T;
function defineConfig<const T extends object>(cfg: T): DeepReadonly<T> {
return cfg as any;
}
const config = defineConfig({
retry: { max: 3, backoffMs: 200 },
endpoint: "/api",
features: ["a", "b"],
});
// config.retry.max = 4; // 타입 에러
여기서 중요한 포인트는 const 제네릭과 DeepReadonly 조합입니다. Records & Tuples가 자리 잡으면 DeepReadonly 같은 타입 체조가 줄고, “값 자체가 불변”이라는 전제가 더 강해집니다.
satisfies로 검증만 하고 리터럴은 유지
설정 객체는 “정해진 스키마를 만족해야 하지만, 리터럴 정보는 유지”가 이상적입니다.
type AppConfig = {
endpoint: string;
retry: { max: number; backoffMs: number };
features: readonly string[];
};
const config2 = {
endpoint: "/api",
retry: { max: 3, backoffMs: 200 },
features: ["a", "b"],
} satisfies AppConfig;
// config2.endpoint는 "/api" 리터럴로 유지될 수 있음
satisfies의 장점과 함정은 위 내부 글(TypeScript 5.x satisfies로 타입오류 조기 차단하기)에서 더 깊게 다뤘습니다.
실전 3: 액션/이벤트 정의를 “데이터”로 만들기
Redux 스타일 액션, 도메인 이벤트, 분석 이벤트는 보통 유니온 타입을 수작업으로 유지하다가 결국 어긋납니다. Records & Tuples가 제공하려는 “값으로 정의하고 타입을 뽑는” 접근이 특히 잘 맞습니다.
const events = {
USER_SIGNED_IN: { name: "USER_SIGNED_IN", payload: { method: "password" as const } },
ITEM_VIEWED: { name: "ITEM_VIEWED", payload: { itemId: 0 as number } },
} as const;
type EventName = keyof typeof events;
type EventOf<N extends EventName> = typeof events[N];
type AnyEvent = EventOf<EventName>;
function track<E extends AnyEvent>(event: E) {
// event.name에 따라 payload 타입이 더 똑똑하게 유지되길 원함
}
track(events.USER_SIGNED_IN);
여기서 한 단계 더 나아가 “생성 함수”를 만들면, 호출부에서 payload를 정확히 강제할 수 있습니다.
function makeEvent<N extends EventName>(name: N, payload: EventOf<N>["payload"]) {
return { name, payload } as const;
}
const e1 = makeEvent("ITEM_VIEWED", { itemId: 123 });
// const e2 = makeEvent("ITEM_VIEWED", { itemId: "x" }); // 타입 에러
Records & Tuples가 값 동등성까지 제공하면, 이벤트 디듀프(같은 이벤트 중복 전송 방지) 같은 로직도 “값 비교”로 더 단순해질 수 있습니다.
실전 4: 라우팅 테이블을 타입과 런타임 단일 소스로 유지
Next.js 같은 프레임워크에서는 라우팅 규칙이 파일 시스템에 묶이기도 하지만, API 라우트나 백엔드 라우팅은 여전히 “테이블”로 관리하는 경우가 많습니다. 이때 Record 기반 테이블은 키 유니온을 그대로 타입으로 뽑기 좋습니다.
type Handler<Req, Res> = (req: Req) => Promise<Res>;
type Routes = {
readonly [path: string]: {
readonly method: "GET" | "POST";
readonly handler: Handler<any, any>;
};
};
const routes = {
"/users": {
method: "GET",
handler: async () => [{ id: 1, name: "Alice" }],
},
"/users/create": {
method: "POST",
handler: async (_req: { name: string }) => ({ ok: true as const }),
},
} as const satisfies Routes;
type Path = keyof typeof routes;
type RouteOf<P extends Path> = (typeof routes)[P];
type MethodOf<P extends Path> = RouteOf<P>["method"];
이제 호출부에서 경로를 고르면 메서드가 자동으로 좁혀집니다.
async function callApi<P extends Path>(path: P, method: MethodOf<P>) {
const route = routes[path];
if (route.method !== method) {
throw new Error("Method mismatch");
}
return route.handler({});
}
callApi("/users", "GET");
// callApi("/users", "POST"); // 타입 단계에서 막히는 방향으로 설계 가능
Records & Tuples 도입 시 주의점 (현실적인 체크리스트)
1) 엔진/트랜스파일러 지원
현재는 제안 기능인 만큼, “바로 프로덕션에서 문법으로 쓰기”는 어렵다고 보는 게 안전합니다. 대신 아래 전략이 현실적입니다.
- 타입 레벨:
const제네릭,satisfies,as const,DeepReadonly로 최대한 의도를 표현 - 런타임 레벨: 필요한 곳에만
Object.freeze(또는 깊은 freeze) 적용 - 캐시 키: 값 동등성이 없으면 안정 직렬화/해시로 대체
2) 값 동등성의 비용 모델
값 동등성은 마법이 아니라 비용이 있습니다. 큰 중첩 구조를 자주 비교하면 비용이 커질 수 있습니다. 따라서 “키로 쓰는 데이터는 작게 유지”하거나, “해시를 병행”하는 설계가 필요합니다.
3) 경계면에서의 변환
외부 입력(JSON)에서 들어온 객체는 기본적으로 가변입니다. Records & Tuples 모델로 넘어가려면, 경계에서 불변 값으로 변환하는 단계가 필요합니다.
아래는 경계에서 “불변 스냅샷”을 만든다는 의도를 드러내는 예시입니다.
function deepFreeze<T>(obj: T): T {
if (!obj || typeof obj !== "object") return obj;
Object.freeze(obj);
for (const key of Object.keys(obj as any)) {
deepFreeze((obj as any)[key]);
}
return obj;
}
type Json = null | boolean | number | string | Json[] | { [k: string]: Json };
function snapshot<const T extends Json>(value: T): DeepReadonly<T> {
return deepFreeze(value) as any;
}
const input = snapshot({ filter: { q: "abc" }, page: 1 });
정리: “불변 값”을 중심으로 TS 추론을 설계하라
Records & Tuples는 단순히 새 자료구조가 아니라, JS가 오랫동안 약했던 “값 중심 프로그래밍”을 언어 레벨로 끌어올리려는 시도입니다. TypeScript 실무에서는 이 변화가 다음을 의미합니다.
- 리터럴을 잃지 않는 API를 설계할수록 타입 추론이 강해진다
- 설정/키/이벤트 같은 “변하면 안 되는 데이터”는 값(불변)으로 다루는 게 맞다
- 값 동등성이 가능해지면 캐시/메모이제이션/디듀프 로직이 단순해진다
지금 당장 네이티브 Record/Tuple 문법이 없더라도, const 제네릭 + satisfies + 깊은 불변 유틸 조합만으로도 “Records & Tuples가 주는 이점”을 상당 부분 선취할 수 있습니다. 이후 런타임 지원이 성숙하면, 타입과 런타임이 같은 방향으로 정렬되면서 코드가 더 단순해질 여지가 큽니다.
추가로 ESM 환경에서 이런 유틸을 패키징하다가 모듈 충돌을 겪는 경우가 많으니, Node 런타임 이슈는 Node.js ERR_REQUIRE_ESM - ESM/CJS 충돌 해결법도 함께 참고해 두면 좋습니다.