Javascript

TypeScript 튜플의 모든 것: 실전 예제로 풀어보는 타입 활용법

드리프트2 2025. 2. 13. 23:08

TypeScript 튜플의 모든 것: 실전 예제로 풀어보는 타입 활용법

JavaScript의 배열은 매우 유연해서 TypeScript는 이를 처리하기 위해 두 가지 다른 타입을 제공합니다:

  • 모든 값이 동일한 타입을 가지는 임의 길이 시퀀스를 위한 배열 타입 – 예: Array<string>
  • 각 요소가 다른 타입을 가질 수 있는 고정 길이 시퀀스를 위한 튜플 타입 – 예: [number, string, boolean]

이번 블로그 포스트에서는 후자에 대해 살펴보겠습니다.

 

특히 타입 레벨에서 튜플을 활용하는 방법에 대해 알아보겠습니다.

이 블로그 포스트에서 사용하는 표기법

소스 코드에서 계산되거나 유추된 타입을 보여주기 위해 npm 패키지 asserttt를 사용합니다. 예를 들면 다음과 같습니다:

// 값의 타입
assertType<string>('abc');
assertType<number>(123);

// 타입의 동일성
type Pair<T> = [T, T];
type _ = Assert<Equal<
  Pair<string>, [string, string]
>>;

튜플 타입의 문법

기본 문법

튜플 타입의 문법은 다음과 같습니다:

[ 필수, 선택?, ...나머지요소[] ]

 

먼저, 필수 요소가 0개 이상 옵니다.


그 다음, 선택 요소가 0개 이상 옵셔널하게 옵니다.


마지막으로, 선택적으로 하나의 나머지 요소가 올 수 있습니다.

 

예를 들면:

type T = [string, boolean?, ...number[]];
const v1: T = ['a', true, 1, 2, 3];
const v2: T = ['a', true];
const v3: T = ['a'];
// @ts-expect-error: Type '[]'은 타입 'T'에 할당할 수 없습니다.
const v4: T = [];

 

추가 규칙이 하나 더 있습니다: 선택 요소가 없을 경우에만, 나머지 요소 뒤에 필수 요소가 올 수 있습니다.

type T1 = [number, ...boolean[], string]; // 가능
// @ts-expect-error: 선택 요소 뒤에 필수 요소는 올 수 없습니다.
type T2 = [number?, ...boolean[], string];

 

선택 요소는 끝에서만 생략할 수 있습니다.

type T = [string, boolean?, ...number[]];

const v1: T = ['a', false, 1, 2, 3]; // 가능
// @ts-expect-error: Type 'number'은 type 'boolean | undefined'에 할당할 수 없습니다.
const v2: T = ['a', 1, 2, 3];
const v3: T = ['a', undefined, 1, 2, 3]; // 가능

 

이는 JavaScript가 매개변수와 구조 분해를 처리하는 방식과 유사합니다. 예를 들면:

function f(x, y=3, ...z) {
  return {x, y, z};
}

 

중간 요소를 생략하려면 유니온을 사용할 수 있습니다:

// `boolean` 요소는 생략될 수 있습니다:
type T =
  | [string, boolean, ...number[]]
  | [string, ...number[]]
;
const v1: T = ['a', false, 1, 2, 3]; // 가능
const v2: T = ['a', 1, 2, 3]; // 가능

 

두 번째 매개변수가 있으면 y에 할당되며 z의 요소가 되지 않습니다.

가변 튜플 요소

가변이란 "변수(고정되지 않은) 길이를 가진"을 의미합니다. 튜플의 길이를 아리티(arity)라고 합니다.

 

가변 요소(또는 스프레드 요소)는 타입 레벨에서 튜플에 스프레딩을 가능하게 합니다:

type Tuple1 = ['a', 'b'];
type Tuple2 = [1, 2];
type _ = Assert<Equal<
  [true, ...Tuple1, ...Tuple2, false], // 타입 표현식
  [ true, 'a', 'b', 1, 2, false ] // 결과
>>;

 

JavaScript에서의 스프레딩과 비교해보면:

const tuple1 = ['a', 'b'];
const tuple2 = [1, 2];
assert.deepEqual(
  [true, ...tuple1, ...tuple2, false], // 표현식
  [ true, 'a', 'b', 1, 2, false ] // 결과
);

 

스프레드되는 타입은 보통 타입 변수이며 readonly any[]에 할당 가능해야 합니다.

 

이는 배열이나 튜플이어야 함을 의미합니다.

 

길이에 제한이 없으므로 "가변"이라는 용어가 사용됩니다.

 

인스턴스화된 제너릭 튜플 타입의 정규화

스프레딩의 결과는 항상 이 섹션 초반에 설명된 형태에 맞게 조정됩니다.

 

이를 탐구하기 위해 유틸리티 타입 Spread1Spread2를 사용해보겠습니다:

type Spread1<T extends unknown[]> = [...T];
type Spread2<T1 extends unknown[], T2 extends unknown[]> =
  [...T1, ...T2]
;
type _ = [
  // 스프레드된 배열만 있는 튜플은 배열이 됩니다:
  Assert<Equal<
    Spread1<Array<string>>,
    string[]
  >>,

  // 배열이 끝에 스프레드되면 나머지 요소가 됩니다:
  Assert<Equal<
    Spread2<['a', 'b'], Array<number>>,
    ["a", "b", ...number[]]
  >>,

  // 두 배열이 스프레드되면 최대 하나의 나머지 요소만 있게 병합됩니다:
  Assert<Equal<
    Spread2<Array<string>, Array<number>>,
    [...(string | number)[]]
  >>,

  // 배열 뒤의 선택 요소는 배열에 병합됩니다:
  Assert<Equal<
    Spread2<Array<string>, [number?, boolean?]>,
    (string | number | boolean | undefined)[]
  >>,

  // 선택 요소 `T` 앞의 required 요소는 `undefined|T`가 됩니다:
  Assert<Equal<
    Spread2<[string?], [number]>,
    [string | undefined, number]
  >>,

  // 배열 사이의 required 요소도 병합됩니다:
  Assert<Equal<
    Spread2<[boolean, ...number[]], [string, ...bigint[]]>,
    [boolean, ...(string | number | bigint)[]]
  >>,
];

 

스프레드는 타입 변수 T가 배열 타입으로 제한될 때만 가능합니다:

type Spread1a<T extends unknown[]> = [...T]; // 가능
// @ts-expect-error: 나머지 요소 타입은 배열 타입이어야 합니다.
type Spread1b<T> = [...T];

 

라벨이 지정된 튜플 요소

 

튜플 요소에 라벨을 지정할 수도 있습니다:

type Interval = [start: number, end: number];

 

한 요소에 라벨을 지정하면 모든 요소에 라벨을 지정해야 합니다.

 

선택 요소의 경우, 라벨에 물음표 ?가 추가됩니다:

type Tuple1 = [string, boolean?, ...number[]];
type Tuple2 = [requ: string, opt?: boolean, ...rest: number[]];

 

라벨은 자동 완성을 돕고 일부 타입 연산에서 유지되지만, 타입 시스템에서 다른 효과는 없습니다.

 

따라서 이름이 중요한 경우 객체 타입을 사용하는 것이 좋습니다.

 

함수 매개변수를 추출하면 라벨이 지정된 튜플 요소가 됩니다

함수 매개변수를 추출하면 라벨이 지정된 튜플 요소가 됩니다:

type _1 = Assert<Equal<
  Parameters<(sym: symbol, bool: boolean) => void>,
  [sym: symbol, bool: boolean]
>>;

 

라벨 자체를 확인하는 방법은 없으므로 다음과 같은 체크도 성공합니다:

// 다른 라벨
type _2 = Assert<Equal<
  Parameters<(sym: symbol, bool: boolean) => void>,
  [HELLO: symbol, EVERYONE: boolean]
>>;

// 라벨 없음
type _3 = Assert<Equal<
  Parameters<(sym: symbol, bool: boolean) => void>,
  [symbol, boolean]
>>;

 

사용 사례: 오버로딩

 

TypeScript는 나머지 매개변수가 튜플 타입인 경우 함수 매개변수로 라벨을 사용합니다:

function f1(...args: [str: string, num: number]) {}
  // function f1(str: string, num: number): void
function f2(...args: [string, number]) {}
  // function f2(args_0: string, args_1: number): void

 

라벨 덕분에 튜플은 오버로딩의 대안으로 더욱 유용해집니다.

 

자동 완성을 통해 매개변수 이름을 표시할 수 있기 때문입니다:

// 튜플을 이용한 오버로딩
function f(
  ...args:
    | [str: string, num: number]
    | [num: number]
    | [bool: boolean]
): void {
  // ···
}
// 전통적인 오버로딩
function f(str: string, num: number): void;
function f(num: number): void;
function f(bool: boolean): void;
function f(arg0: string | number | boolean, num?: number): void {
  // ···
}

 

단점은 튜플이 반환 타입에 영향을 미칠 수 없다는 점입니다.

 

함수 변환 시 인수 이름 보존

이 방법은 이후 포스트에서 부분 적용을 처리할 때 실제로 어떻게 동작하는지 보여줍니다.

 

튜플을 위한 타입

튜플과 --noUncheckedIndexedAccess

tsconfig.json 옵션 noUncheckedIndexedAccess를 활성화하면 TypeScript는 인덱스 가능한 타입에 대해 더 명확한 타입을 제공합니다.

 

배열의 경우, TypeScript는 컴파일 시점에 특정 인덱스에 요소가 있는지 알 수 없으므로 인덱싱 읽기 시 항상 undefined이 가능한 결과가 됩니다:

const arr: Array<string> = ['a', 'b', 'c'];
const arrayElement = arr[1];
assertType<string | undefined>(arrayElement);

 

반면, 튜플의 경우 전체 구조를 알고 있어 인덱싱 읽기에 대한 더 정확한 타입을 제공합니다:

const tuple: [string, string, string] = ['a', 'b', 'c'];
const tupleElement = tuple[1];
assertType<string>(tupleElement);

 

배열 리터럴을 튜플로 강제 추론

 

기본적으로 JavaScript 배열 리터럴은 배열 타입을 가집니다:

// 배열
const value1 = ['a', 1];
assertType<
  (string | number)[]
>(value1);

 

이를 변경하는 가장 일반적인 방법은 as const 주석을 사용하는 것입니다:

// 튜플
const value2 = ['a', 1] as const;
assertType<
  readonly ["a", 1]
>(value2);

 

하지만 satisfies를 사용할 수도 있습니다:

// 비어있지 않은 튜플
const value3 = ['a', 1] satisfies [unknown, ...unknown[]];
assertType<
  [string, number]
>(value3);

// (아마도) 비어있을 수 있는 튜플
const value4 = ['a', 1] satisfies [unknown?, ...unknown[]];
assertType<
  [string, number]
>(value4);

 

as const는 요소 타입을 'a'1로 좁히지만, satisfies는 그렇지 않습니다.

 

요소 앞에 as const를 사용하면 다음과 같이 됩니다:

// 튜플
const value5 = [
  'a' as const, 1 as const
] satisfies [unknown?, ...unknown[]];
assertType<
  ["a", 1]
>(value5);

 

나머지 요소 앞에 튜플 요소를 생략하면 다시 배열 타입이 됩니다:

// 배열
const value6 = ['a', 1] satisfies [...unknown[]];
assertType<
  (string | number)[]
>(value6);

 

튜플에 사용할 수 있는 다른 타입도 있습니다:

// 튜플
const value7 = ['a', 1] satisfies unknown[] | [];
assertType<
  [string, number]
>(value7);

readonly를 사용하여 const 튜플 수용

 

타입 T가 일반 배열 타입으로 제한되면 as const 리터럴 타입과 일치하지 않습니다:

type Tuple<T extends Array<unknown>> = T;
const arr = ['a', 'b'] as const;
// @ts-expect-error: Type 'readonly ["a", "b"]'는 제약 조건 'unknown[]'을 만족하지 않습니다.
type _ = Tuple<typeof arr>;

 

이를 ReadonlyArray로 전환하면 해결할 수 있습니다:

type Tuple<T extends ReadonlyArray<unknown>> = T;
const arr = ['a', 'b'] as const;
type Result = Tuple<typeof arr>;
type _ = Assert<Equal<
  Result, readonly ["a", "b"]
>>;

 

다음 두 표기는 동등합니다:

  • ReadonlyArray<unknown>
  • readonly unknown[]

 

고정된 배열 길이 강제

다음과 같은 트릭을 사용하여 배열 리터럴의 고정 길이를 강제할 수 있습니다:

function join3<T extends string[] & {length: 3}>(...strs: T) {
  return strs.join('');
}
join3('a', 'b', 'c'); // 가능

// @ts-expect-error: 인수 타입 '["a", "b"]'는 'string[] & { length: 3; }' 타입에 할당할 수 없습니다.
join3('a', 'b');

 

단점은 strs가 배열 변수에서 오는 경우 이 기술이 작동하지 않는다는 점입니다:

const arr = ['a', 'b', 'c'];
// @ts-expect-error: 인수 타입 'string[]''string[] & { length: 3; }' 타입에 할당할 수 없습니다.
join3(...arr);

 

반대로, 튜플은 작동합니다:

const tuple = ['a', 'b', 'c'] as const;
join3(...tuple);

 

튜플에서 유니온 타입 추출

객체에 인덱스 접근 연산자 적용

TypeScript에는 내장된 keyof 연산자가 있습니다.

 

인덱스 접근 연산자를 사용하여 유틸리티 타입 ValueOf를 구현할 수 있습니다:

const englishToGerman = {
  yes: 'ja',
  no: 'nein',
} as const;

type ValueOf<Obj> = Obj[keyof Obj]; // (A)
type Values = ValueOf<typeof englishToGerman>;
type _ = Assert<Equal<
  Values, "ja" | "nein"
>>;

 

라인 A에서는 대괄호 안의 키 타입이 구체적이어야 합니다.

 

예를 들어 string이나 string | number | symbol을 사용하면 TypeScript가 오류를 발생시킵니다.

 

튜플에 인덱스 접근 연산자 적용

튜플에 인덱스 접근 연산자를 적용하면 튜플 요소들이 유니온으로 반환됩니다:

const flowers = ['rose', 'sunflower', 'lavender'] as const;

type ElementOf<Tup extends readonly unknown[]> = Tup[number]; // (A)
type Elements = ElementOf<typeof flowers>;
type _ = Assert<Equal<
  Elements, "rose" | "sunflower" | "lavender"
>>;

 

이 경우, TypeScript는 인덱스 접근 연산자에 대해 number 타입을 사용할 수 있게 해줍니다.

 

튜플의 튜플에서 유니온 추출

튜플의 튜플에서 유니온을 추출하는 것도 가능합니다:

const englishSpanishGerman = [
  ['yes', 'sí', 'ja'],
  ['no', 'no', 'nein'],
  ['maybe', 'tal vez', 'vielleicht'],
] as const;

type English = (typeof englishSpanishGerman)[number][0];
type _1 = Assert<Equal<
  English, "yes" | "no" | "maybe"
>>;

type Spanish = (typeof englishSpanishGerman)[number][1];
type _2 = Assert<Equal<
  Spanish, "sí" | "no" | "tal vez"
>>;

 

튜플의 객체에서 유니온 추출

 

튜플의 객체에서도 동일한 접근 방식을 사용할 수 있습니다:

const listCounterStyles = [
  { name: 'upperRoman', regExp: /^[IVXLCDM]+$/ },
  { name: 'lowerRoman', regExp: /^[ivxlcdm]+$/ },
  { name: 'upperLatin', regExp: /^[A-Z]$/ },
  { name: 'lowerLatin', regExp: /^[a-z]$/ },
  { name: 'decimal',    regExp: /^[0-9]+$/ },
] as const satisfies Array<{regExp: RegExp, name: string}>;

type CounterNames = (typeof listCounterStyles)[number]['name'];
type _ = Assert<Equal<
  CounterNames,
  | "upperRoman" | "lowerRoman"
  | "upperLatin" | "lowerLatin"
  | "decimal"
>>;

 

매핑 타입을 통한 튜플 매핑

 

매핑 타입의 문법은 다음과 같습니다:

type MapOverType<Type> = {
  [Key in keyof Type]: Promise<Type[Key]>
};

 

매핑 타입은 객체뿐만 아니라 튜플에도 동일하게 동작합니다:

type WrapValues<T> = {
  [Key in keyof T]: Promise<T[Key]>
};
type _ = Assert<Equal<
  WrapValues<['a', 'b']>,
  [Promise<"a">, Promise<"b">]
>>;

 

또 다른 예는 다음과 같습니다:

type Unwrap<T> =
  T extends Promise<infer C> ? C : never;
type UnwrapValues<T> = {
  [Key in keyof T]: Unwrap<T[Key]>
};
type _ = Assert<Equal<
  UnwrapValues<[Promise<string>, Promise<number>]>,
  [string, number]
>>;

 

매핑은 타입의 종류를 유지합니다 (튜플, 배열, 객체 등)

 

매핑 타입의 입력이 튜플, 배열, 객체 등에 따라 출력이 어떻게 보이는지 달라집니다:

type WrapValues<T> = {
  [Key in keyof T]: Promise<T[Key]>
};

const tuple = ['a', 'b'] as const;
type _1 = Assert<Equal<
  WrapValues<typeof tuple>,
  readonly [Promise<"a">, Promise<"b">]
>>;

const array1 = ['a', 'b'];
type _2 = Assert<Equal<
  WrapValues<typeof array1>,
  Promise<string>[]
>>;

const array2 = ['a' as const, 'b' as const];
type _3 = Assert<Equal<
  WrapValues<typeof array2>,
  Promise<"a" | "b">[]
>>;

const obj1 = {a: 1, b: 2};
type _4 = Assert<Equal<
  WrapValues<typeof obj1>,
  { a: Promise<number>, b: Promise<number> }
>>;

const obj2 = {a: 1, b: 2} as const;
type _5 = Assert<Equal<
  WrapValues<typeof obj2>,
  { readonly a: Promise<1>, readonly b: Promise<2> }
>>;

 

매핑은 튜플 요소의 라벨을 보존합니다

 

매핑은 튜플 요소의 라벨도 보존합니다:

type WrapValues<T> = {
  [Key in keyof T]: Promise<T[Key]>
};
type _ = Assert<Equal<
  WrapValues<[a: number, b: number]>,
  [a: Promise<number>, b: Promise<number>]
>>;

 

타입을 활용한 컴퓨팅

 

이번 섹션에서는 작은 예제를 통해 타입을 활용한 컴퓨팅을 탐구해보겠습니다.

튜플의 일부를 추출하기

튜플의 일부를 추출하려면 infer를 사용합니다.

튜플의 첫 번째 요소 추출

튜플의 첫 번째 요소를 추출하고 나머지는 무시하려면 unknown을 와일드카드 타입으로 사용합니다:

type First<T extends Array<unknown>> =
T extends [infer F, ...unknown[]]
  ? F
  : never
;
type _ = Assert<Equal<
  First<['a', 'b', 'c']>,
  'a'
>>;
튜플의 마지막 요소 추출

첫 번째 요소를 추출한 방식과 유사하게 마지막 요소를 추출할 수 있습니다:

type Last<T extends Array<unknown>> =
T extends [...unknown[], infer L]
  ? L
  : never
;
type _ = Assert<Equal<
  Last<['a', 'b', 'c']>,
  'c'
>>;
튜플의 나머지 요소 추출 (첫 번째 요소 이후)

첫 번째 요소 이후의 나머지 요소를 추출하려면 unknowninfer를 사용합니다:

type Rest<T extends Array<unknown>> =
T extends [unknown, ...infer R]
  ? R
  : never
;
type _ = Assert<Equal<
  Rest<['a', 'b', 'c']>,
  ['b', 'c']
>>;

튜플 연결하기

두 튜플 T1T2를 연결하려면 두 튜플을 스프레드합니다:

type Concat<T1 extends Array<unknown>, T2 extends Array<unknown>> =
  [...T1, ...T2]
;
type _ = Assert<Equal<
  Concat<['a', 'b'], ['c', 'd']>,
  ['a', 'b', 'c', 'd']
>>;

튜플에서의 재귀

재귀를 활용하여 튜플 요소를 감싸는 방법을 구현해보겠습니다:

type WrapValues<Tup> =
  Tup extends [infer First, ...infer Rest] // (A)
    ? [Promise<First>, ...WrapValues<Rest>] // (B)
    : [] // (C)
;
type _ = Assert<Equal<
  WrapValues<['a', 'b', 'c']>,
  [Promise<'a'>, Promise<'b'>, Promise<'c'>]
>>;

 

 

튜플의 튜플 평탄화

재귀를 사용하여 튜플의 튜플을 평탄화해보겠습니다:

type Flatten<Tups extends Array<Array<unknown>>> =
  Tups extends [
    infer Tup extends Array<unknown>, // (A)
    ...infer Rest extends Array<Array<unknown>> // (B)
  ]
    ? [...Tup, ...Flatten<Rest>]
    : []
;
type _ = Assert<Equal<
  Flatten<[['a', 'b'], ['c', 'd'], ['e']]>,
  ['a', 'b', 'c', 'd', 'e']
>>;

 

이 경우, 추론된 타입 TupRest는 더 복잡해지므로 extends를 사용하여 제한을 두었습니다.

 

튜플 필터링

재귀를 통해 튜플에서 빈 문자열을 필터링하는 예제입니다:

type RemoveEmptyStrings<T extends Array<string>> =
  T extends [
    infer First extends string,
    ...infer Rest extends Array<string>
  ]
    ? First extends ''
      ? RemoveEmptyStrings<Rest>
      : [First, ...RemoveEmptyStrings<Rest>]
    : []
;
type _ = Assert<Equal<
  RemoveEmptyStrings<['', 'a', '', 'b', '']>,
  ["a", "b"]
>>;

 

주어진 길이의 튜플 생성

 

주어진 길이 Len을 가지는 튜플을 생성하려면 다음과 같은 방법을 사용할 수 있습니다:

type Repeat<
  Len extends number, Value,
  Acc extends Array<unknown> = []
> = 
  Acc['length'] extends Len // (A)
    ? Acc // (B)
    : Repeat<Len, Value, [...Acc, Value]> // (C)
;

type _ = [
  Assert<Equal<
    Repeat<3, '*'>,
    ["*", "*", "*"]
  >>,
  Assert<Equal<
    Repeat<3, string>,
    [string, string, string]
  >>,
  Assert<Equal<
    Repeat<3, unknown>,
    [unknown, unknown, unknown]
  >>,
];

 

라인 A에서는 Acc의 길이가 Len과 동일한지 확인하고, 그렇다면 Acc를 반환합니다.

 

그렇지 않다면 AccValue를 추가하고 재귀 호출합니다.

 

숫자 범위 계산

같은 기술을 사용하여 숫자 범위를 계산할 수 있습니다:

type NumRange<Upper extends number, Acc extends number[] = []> =
  Upper extends Acc['length']
    ? Acc
    : NumRange<Upper, [...Acc, Acc['length']]>
;
type _ = Assert<Equal<
  NumRange<3>,
  [0, 1, 2]
>>;

 

초기 요소 제거

 

튜플에서 처음 Num개의 요소를 제거하는 유틸리티 타입입니다:

type Drop<
  Tuple extends Array<unknown>,
  Num extends number,
  Counter extends Array<boolean> = []
> =
  Counter['length'] extends Num
    ? Tuple
    : Tuple extends [unknown, ...infer Rest extends Array<unknown>]
      ? Drop<Rest, Num, [true, ...Counter]>
      : Tuple
;
type _ = Assert<Equal<
  Drop<['a', 'b', 'c'], 2>,
  ["c"]
>>;

 

이번에는 누산 변수 Counter를 사용하여 Num과 동일한 길이가 될 때까지 세어갑니다.

 

또한, 다음과 같이 추론을 사용할 수도 있습니다 (Heribert Schütz의 아이디어):

type Drop<
  Tuple extends Array<unknown>,
  Num extends number
> =
  Tuple extends [...Repeat<Num, unknown>, ...infer Rest]
    ? Rest
    : never
;

 

실세계 예제

 

부분 적용으로 매개변수 이름 보존

부분 적용 함수 applyPartial을 구현해보겠습니다.

 

이는 함수 메소드 .bind()와 유사하게 작동합니다:

function applyPartial<
  Func extends (...args: any[]) => any,
  InitialArgs extends unknown[],
>(func: Func, ...initialArgs: InitialArgs) {
  return (...remainingArgs: RemainingArgs<Func, InitialArgs>)
  : ReturnType<Func> => {
    return func(...initialArgs, ...remainingArgs);
  };
}

//----- 테스트 -----

function add(x: number, y: number): number {
  return x + y;
}
const add3 = applyPartial(add, 3);
type _1 = Assert<Equal<
  typeof add3,
  // 매개변수 이름이 보존됩니다!
  (y: number) => number
>>;

 

부분적으로 적용된 func를 반환합니다.

remainingArgs의 타입을 계산하기 위해 다음 유틸리티 타입을 사용합니다:

type RemainingArgs<
  Func extends (...args: any[]) => any,
  InitialArgs extends unknown[],
> =
  Func extends (
    ...args: [...InitialArgs,
    ...infer TrailingArgs]
  ) => unknown
    ? TrailingArgs
    : never
;

//----- 테스트 -----

type _2 = Assert<Equal<
  RemainingArgs<typeof add, [number]>,
  [y: number]
>>;

zip() 함수 타입 지정

 

zip() 함수는 반복 가능한 튜플을 튜플의 반복 가능한 형태로 변환합니다.

 

다음은 그에 대한 타입 유틸리티 Zip입니다:

type Zip<Tuple extends Array<Iterable<unknown>>> =
  Iterable<
    { [Key in keyof Tuple]: UnwrapIterable<Tuple[Key]> }
  >
;
type UnwrapIterable<Iter> =
  Iter extends Iterable<infer T>
    ? T
    : never
;

type _ = Assert<Equal<
  Zip<[Iterable<string>, Iterable<number>]>,
  Iterable<[string, number]>
>>;

zipObj() 함수 타입 지정

 

zipObj() 함수는 객체의 반복 가능한 값을 객체의 반복 가능한 형태로 변환합니다.

 

이에 대한 타입 유틸리티 ZipObj는 다음과 같습니다:

type ZipObj<Obj extends Record<string, Iterable<unknown>>> =
  Iterable<
    { [Key in keyof Obj]: UnwrapIterable<Obj[Key]> }
  >
;
type UnwrapIterable<Iter> =
  Iter extends Iterable<infer T>
    ? T
    : never
;

type _ = Assert<Equal<
  ZipObj<{a: Iterable<string>, b: Iterable<number>}>,
  Iterable<{a: string; b: number}>
>>;

util.promisify(): 콜백 기반 함수를 Promise 기반으로 변환

 

Node.js의 util.promisify 함수는 콜백을 통해 결과를 반환하는 함수를 Promise를 통해 반환하는 함수로 변환합니다.

 

공식 타입은 다음과 같습니다:

// 인수 0개
export function promisify<TResult>(
    fn: (callback: (err: any, result: TResult) => void) => void,
): () => Promise<TResult>;
export function promisify(
  fn: (callback: (err?: any) => void) => void
): () => Promise<void>;

// 인수 1개
export function promisify<T1, TResult>(
    fn: (arg1: T1, callback: (err: any, result: TResult) => void) => void,
): (arg1: T1) => Promise<TResult>;
export function promisify<T1>(
  fn: (arg1: T1, callback: (err?: any) => void) => void
): (arg1: T1) => Promise<void>;

// 인수 2개
export function promisify<T1, T2, TResult>(
    fn: (arg1: T1, arg2: T2, callback: (err: any, result: TResult) => void) => void,
): (arg1: T1, arg2: T2) => Promise<TResult>;
export function promisify<T1, T2>(
    fn: (arg1: T1, arg2: T2, callback: (err?: any) => void) => void,
): (arg1: T1, arg2: T2) => Promise<void>;

// 기타: 최대 5개 인수까지

 

이를 단순화해보겠습니다:

function promisify<Args extends any[], CB extends NodeCallback>(
  fn: (...args: [...Args, CB]) => void,
): (...args: Args) => Promise<ExtractResultType<CB>> {
  // ···
}
type NodeCallback =
  | ((err: any, result: any) => void)
  | ((err: any) => void)
;

//----- 테스트 -----

function nodeFunc(
  arr: Array<string>,
  cb: (err: Error, str: string) => void
) {}
const asyncFunc = promisify(nodeFunc);
assertType<
  (arr: string[]) => Promise<string>
>(asyncFunc);

 

이전 코드는 다음 유틸리티 타입을 사용합니다:

type ExtractResultType<F extends NodeCallback> =
  F extends (err: any) => void
  ? void
  : F extends (err: any, result: infer TResult) => void
  ? TResult
  : never
;

//----- 테스트 -----

type _ = [
  Assert<Equal<
    ExtractResultType<(err: Error, result: string) => void>,
    string
  >>,
  Assert<Equal<
    ExtractResultType<(err: Error) => void>,
    void
  >>,
];

 

튜플 컴퓨팅의 한계

 

TypeScript의 타입 시스템으로는 표현할 수 없는 제약이 있습니다.

 

예를 들어 다음 코드는 이를 보여줍니다:

type Same<T> = {a: T, b: T};

function one<T>(obj: Same<T>) {}
// @ts-expect-error: Type 'string'은 타입 'boolean'에 할당할 수 없습니다.
one({a: false, b: 'abc'}); // 👍 오류

function many<A, B, C, D, E>(
  objs: [Same<A>, Same<B>]
      | [Same<A>, Same<B>, Same<C>]
      | [Same<A>, Same<B>, Same<C>, Same<D>]
      | [Same<A>, Same<B>, Same<C>, Same<D>, Same<E>,
        ...Array<Same<unknown>>]
) {}

many([
  {a: true, b: true},
  {a: 'abc', b: 'abc'},
  // @ts-expect-error: Type 'boolean'은 타입 'number'에 할당할 수 없습니다.
  {a: 7, b: false} // 👍 오류
]);

 

우리는 다음과 같이 표현하고 싶습니다:

  • many() 함수는 객체의 배열을 받습니다.
  • 두 속성의 타입이 동일해야 합니다.

하지만 TypeScript의 타입 시스템으로는 루프를 통해 한 번에 하나의 변수를 도입할 수 없기 때문에 가장 일반적인 경우를 수동으로 나열해야 합니다.

 


TypeScript의 튜플은 고정된 길이와 각기 다른 타입의 요소를 갖는 배열을 다룰 때 매우 유용하다.

 

선택적 요소(?), 가변 요소(...)를 활용하면 더욱 강력한 타입을 만들 수 있다.
 
튜플 변환 및 타입 연산을 활용하면 복잡한 타입 로직을 간결하게 표현할 수 있다.
 

TypeScript로 안정적인 코드를 작성하려면, 배열과 튜플의 차이를 이해하고 적절한 상황에서 활용하는 것이 중요하다.