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

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[]가 더 유연하지만 타입 안전성을 일부 포기한다. 라이브러리를 작성할 때는 의도에 따라 선택하자.


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

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


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