가변 인자 튜플 — 스프레드와 추론으로 복잡한 타입 다루기

TypeScript 4.0에서 도입된 가변 인자 튜플 타입의 스프레드 문법, 타입 추론, Concat/Prepend/Append 패턴을 완전히 정리합니다.

· 9 min read · PALDYN Team

지난 글에서 기본 튜플 타입을 살펴봤다. 이번 글에서는 TypeScript 4.0에서 도입된 가변 인자 튜플 타입(Variadic Tuple Types)을 다룬다. 가변 인자 튜플은 튜플 타입에 제네릭 스프레드를 결합해, 타입 수준에서 배열을 자유롭게 연결하고 변형할 수 있게 해준다. 처음에는 낯설게 느껴지지만, 한번 익히면 고급 유틸리티 타입을 직접 만들 때 없어서는 안 될 도구가 된다.

가변 인자 튜플 스프레드

가변 인자 튜플이란

기존 TypeScript에서는 튜플 타입 안에 제네릭 타입 변수를 스프레드할 수 없었다. TypeScript 4.0이 이 제약을 풀면서, 다음과 같은 코드가 가능해졌다.

// TS 4.0 이전에는 불가능했던 패턴
type Concat<A extends any[], B extends any[]> = [...A, ...B];

type Result = Concat<[1, 2], [3, 4]>; // [1, 2, 3, 4]

핵심은 ...A처럼 제네릭 타입 변수를 스프레드할 수 있다는 점이다. A extends any[]로 A가 배열 타입임을 제약하면, ...A는 해당 배열의 모든 요소를 펼쳐 넣는다.

// 가변 인자 튜플의 기본 규칙
// 1. 타입 변수는 any[]를 extends해야 함
// 2. 스프레드는 튜플 타입 내에서만 사용 가능
// 3. 하나의 튜플에 rest 스프레드는 하나만

type Valid<A extends any[]> = [...A, string];   // OK
type AlsoValid<A extends any[], B extends any[]> = [...A, ...B]; // OK

타입 수준 스프레드 연산

가변 인자 튜플의 스프레드는 값 수준의 스프레드 연산자(...)와 완전히 대응한다. 함수가 하는 일을 타입이 그대로 추적한다.

// 값 수준
function concat<A extends any[], B extends any[]>(a: A, b: B): [...A, ...B] {
  return [...a, ...b];
}

// 타입이 정확히 추론됨
const result = concat([1, 2] as [1, 2], ["a", "b"] as ["a", "b"]);
// result: [1, 2, "a", "b"] — 구체적인 리터럴 타입까지 보존!

// 일반 배열을 넘기면 일반 배열 타입
const r2 = concat([1, 2], [3, 4]);
// r2: number[] — 배열이면 배열로 추론

TS 4.0 이전에는 이런 함수의 반환 타입을 (A | B)[]로밖에 표현할 수 없었다. 이제는 입력 튜플의 구조를 정확히 반영한 타입을 돌려줄 수 있다.

Concat, Prepend, Append 패턴

가변 인자 튜플로 만들 수 있는 대표적인 유틸리티 타입들이다.

Concat

두 튜플을 이어 붙인다.

type Concat<A extends any[], B extends any[]> = [...A, ...B];

type AB = Concat<[1, 2], [3, 4]>;       // [1, 2, 3, 4]
type Mixed = Concat<[string], [number, boolean]>; // [string, number, boolean]
type Empty = Concat<[], [1, 2, 3]>;     // [1, 2, 3]

Prepend

튜플 앞에 요소를 추가한다.

type Prepend<T, Arr extends any[]> = [T, ...Arr];

type P1 = Prepend<0, [1, 2, 3]>;    // [0, 1, 2, 3]
type P2 = Prepend<string, [number]>; // [string, number]

Append

튜플 뒤에 요소를 추가한다.

type Append<Arr extends any[], T> = [...Arr, T];

type A1 = Append<[1, 2, 3], 4>;     // [1, 2, 3, 4]
type A2 = Append<[string], boolean>; // [string, boolean]

조합 활용

// 앞뒤에 동시에 추가
type Wrap<T, Arr extends any[], U> = [T, ...Arr, U];

type Wrapped = Wrap<string, [number, boolean], null>;
// [string, number, boolean, null]

가변 인자 튜플 추론 패턴

함수 매개변수와 가변 인자 튜플

가변 인자 튜플의 진가는 함수 매개변수 타입을 다룰 때 드러난다. TypeScript 내장 유틸리티 ParametersReturnType이 이 기반 위에서 동작한다.

// 표준 라이브러리 정의 (개념적으로)
type Parameters<T extends (...args: any) => any>
  = T extends (...args: infer P) => any ? P : never;

type ReturnType<T extends (...args: any) => any>
  = T extends (...args: any) => infer R ? R : never;

// 활용 예시
function greet(name: string, age: number): string {
  return `${name} (${age})`;
}

type GreetParams = Parameters<typeof greet>;   // [name: string, age: number]
type GreetReturn = ReturnType<typeof greet>;   // string

가변 인자 튜플로 매개변수 타입을 직접 조작할 수 있다:

// 첫 번째 인자를 제거하는 유틸리티
type DropFirst<T extends any[]> = T extends [any, ...infer Rest] ? Rest : never;

type F = (a: string, b: number, c: boolean) => void;
type WithoutFirst = DropFirst<Parameters<F>>; // [b: number, c: boolean]

실전 활용 예시

함수 바인딩 타입

// bind()의 타입을 더 정확하게 표현
type Bind<F extends (...args: any[]) => any, BoundArgs extends any[]> = (
  ...args: DropFirst<Parameters<F>>
) => ReturnType<F>;

// 미들웨어 파이프라인
type Middleware<In extends any[], Out> = (...args: In) => Out;

type Pipeline<Args extends any[], Mid, Out> = [
  first: Middleware<Args, Mid>,
  second: Middleware<[Mid], Out>
];

이벤트 시스템

type EventMap = {
  click: [x: number, y: number];
  keydown: [key: string, modifiers: string[]];
  resize: [width: number, height: number];
};

function on<K extends keyof EventMap>(
  event: K,
  handler: (...args: EventMap[K]) => void
): void {
  // 구현 생략
}

// args가 [x: number, y: number]로 정확히 추론됨
on("click", (x, y) => {
  console.log(x + y);
});

Curry 타입

가변 인자 튜플로 커링 함수의 타입을 표현할 수도 있다.

// 단순 2인자 커링
type Curry2<A, B, R> = (a: A) => (b: B) => R;

// 매개변수 배열로부터 커링 타입 생성
type CurryArgs<Args extends any[], R> =
  Args extends [infer First, ...infer Rest]
    ? (arg: First) => CurryArgs<Rest, R>
    : R;

type Curried = CurryArgs<[string, number, boolean], string>;
// (arg: string) => (arg: number) => (arg: boolean) => string

주의사항과 한계

rest 위치 제한 (TS 4.2 이전)

TypeScript 4.2 이전에는 rest 요소가 마지막 위치에만 올 수 있었다. 4.2부터 중간 위치도 허용되지만, 여전히 제약이 있다.

// TS 4.2+: 중간 rest 허용
type Middle = [first: string, ...middle: number[], last: boolean]; // OK

// 단, rest 요소는 하나의 튜플에 하나만
// type TwoRests = [...string[], ...number[]]; // Error

재귀적 사용의 복잡성

가변 인자 튜플을 재귀적으로 사용하면 타입 추론이 느려지거나 복잡해질 수 있다.

// 깊은 재귀는 타입 연산이 무거워짐
type Reverse<T extends any[], Acc extends any[] = []> =
  T extends [infer Head, ...infer Tail]
    ? Reverse<Tail, [Head, ...Acc]>
    : Acc;

type Rev = Reverse<[1, 2, 3, 4, 5]>; // [5, 4, 3, 2, 1]
// 긴 튜플에서는 성능 주의

any[]와 unknown[]의 차이

제약에 any[]unknown[]은 다르게 동작한다:

// any[]를 extends하면 스프레드 가능
type WithAny<T extends any[]> = [...T, string]; // OK

// unknown[]도 스프레드 가능 (더 안전)
type WithUnknown<T extends unknown[]> = [...T, string]; // OK

// 단, unknown[]로 제약하면 배열 원소 타입이 unknown으로 처리됨

실제 코드에서는 any[]가 더 유연하지만 타입 안전성을 일부 포기한다. 라이브러리를 작성할 때는 의도에 따라 선택하자.


지난 글: 튜플 타입 — 고정 길이 이종 배열의 완전 정복

다음 글: 객체 타입 완전 정리 — 프로퍼티·옵셔널·인덱스 시그니처


읽어주셔서 감사합니다. 😊