가변 인자 튜플 (Variadic Tuple Types)

DatQueue·2022년 9월 6일
0
post-thumbnail

지난 시간에 "튜플"이란 무엇인지 알아봄과 동시에 타입스크립트에서 "튜플"이 어떻게 쓰이고 어떠한 키워드로써 동작되는지 살펴보았다.

이번 시간에 알아볼 주제는 "Variadic Tuple(가변 인자 튜플)"이다.

Variadic ?

: variable + -adic (”임의의 많은 변수를 취하는” 뜻)

위의 “Variadic”이란 단어의 어원과 같이 “Variadic”은 우리가 흔히 알고 있는 “Rest Parameter”이다.

해당 개념은 TypeScript 4.0부터 도입된 키워드이다. 즉, 4.0 버전 이전에는 쓸 수 없었던 개념이다. 가변 인자 튜플이 왜 도입되었고 , 어떠한 장점이 있는지를 포커스로 두고 포스팅을 참조하면 좋을 것 같다.

또한 해당 개념을 이해하는데 선수 지식으로 알면 좋을 몇가지 다른 개념들이 등장한다. 모두 함께 알아볼 예정이다.

이번 포스팅은 타입스크립트 공식 핸드북을 참고로 진행한다. ⬇ ⬇ ⬇
https://www.typescriptlang.org/ko/docs/handbook/release-notes/typescript-4-0.html

기존 튜플 타입 정의 방법


먼저, 배열이나 튜플 타입 두 개를 결합하여 새로운 배열을 만드는 JavaScript의 concat함수에 대해 생각해보자.

function concat (arr1 , arr2) {
  return [...arr1 , ...arr2];
}

concat(["a","b","c"] , ["A","B","C"]);  // (6) ['a', 'b', 'c', 'A', 'B', 'C']

JavaScript의 concat이란 메소드를 접해보았을 것이다. 거기서 함수명을 따 concat함수를 만들었고 , 파라미터로 arr1arr2를 받아 배열을 병합해보았다.

그리고, 배열이나 튜플을 변수로 입력받아 첫 번째 원소를 제외한 나머지를 반환하는 tail함수에 대해서 생각해보자.

function tail(arg) {
  const [_ ,...result] = arg;   // "_"는 임의로 지정
  return result;
}

tail(["a","b","c"]);  // ['b', 'c']

그렇다면, TypeScript에서는 이 두 함수의 타입을 어떻게 정의할 수 있을까?

concat의 경우, 이전 버전(TypeScript 4.0이전)에서는 여러 개의 오버로드를 작성하는 방법이 유일했다.

오버로딩(Overloading)

“오버로딩(overloading)”이란 같은 이름의 함수(or 메서드)를 매개변수의 타입과 갯수를 다르게 여러개 정의하는 것이다.

오버로딩을 통해 다양한 매개변수에 응답하는 함수(or 메서드)를 구현할 수 있다.

함수 파라미터에 들어갈 타입을 알고 있고, 파라미터 타입만 달라지고 함수의 로직이 반복된다면 함수 오버로딩을 사용한다.

function concat(arr1 : [] , arr2 : []) : [];
function concat<A>(arr1 : [A] , arr2 : []) : [A];
function concat<A,B>(arr1 : [A,B] , arr2 : []) : [A,B];
function concat<A,B,C>(arr1 : [A,B,C], arr2 : []) : [A,B,C];
function concat<A,B,C,D>(arr1 : [A,B,C,D] , arr2 : []) : [A,B,C,D];

현재 위의 오버로드들의 두 번째 배열(arr2)은 전부 비어있다. 만약 , arr2가 하나의 인자(A2)를 가지고 있다면 어떨까?

function concat<A2>(arr1: [], arr2: [A2]): [A2];
function concat<A1, A2>(arr1: [A1], arr2: [A2]): [A1, A2];
function concat<A1, B1, A2>(arr1: [A1, B1], arr2: [A2]): [A1, B1, A2];
function concat<A1, B1, C1, A2>(arr1: [A1, B1, C1], arr2 : [A2]): [A1, B1, C1, A2];
function concat<A1, B1, C1, D1, A2>(arr1: [A1, B1, C1, D1], arr2: [A2]): [A1, B1, C1, D1, A2];

파라미터의 타입이 어떻게 달라질지 모르기 때문에 일일이 이렇게 함수 오버로드 구조로써 타입을 제시한 것이다.

이것이 과연 바람직하다고 할 수 있을까?

아니다, 굉장히 비합리적이다. tail함수를 타이핑 할 때도 마찬가지의 문제에 직면할 것이다.

이것을 우린 “천 개의 오버로드로 인한 죽음(death by a thousand overloads)”라고 일컫을 수 있다.

우리가 작성하고자 하는 만큼의 오버로드를 기입해야하고 그에 한해서만 올바른 타입을 제공해 준다.

포괄적 케이스

만약 포괄적인 케이스를 만들고 싶다면

function concat<T,U>(arr1 : T[] , arr2 : U[]) : Array<T | U> {
  return [...arr1 , ...arr2]
}

const concatArray1 = concat(["a","b"] , ["c"]);
const concatArray2 = concat(["a","b","c"] , ["d"]);

console.log(concatArray1);  //(3) ['a', 'b', 'c']
console.log(concatArray2);  //(4) ['a', 'b', 'c', 'd']

concat함수를 위와 같이 정의하면 된다.

그러나 위와 같은 구조를 “튜플”이라고 보긴 어렵다.

우리가 앞서 정의한 튜플의 의미와 역할을 다시 짚어보자.

튜플은 길이와 각 요소마다의 타입이 고정된 배열이다

그렇다. 앞전의 시그니처는 튜플을 사용할 때 입력 길이나 요소 순서에 대한 어떤 것도 처리하지 않는다.

4.0 이후 새롭게 도입된 튜플 타입 정의


TypeScript는 타입 추론 개선을 포함한 **두 가지** 핵심적인 변화를 도입해 이러한 타이핑을 가능하도록 만들었다.

첫 번째 변화 - 제네릭 타입 사용

번째 변화는 튜플 타입 구문의 스프레드 연산자에서 제네릭 타입을 사용할 수 있다는 점이다.

사용중인 실제 타입을 모르더라도 튜플이나 배열을 처리하는데 있어서 타입 추론이 가능하게 된다.

이러한 튜플 타입에서 제네릭 스프레드 연산자가 인스턴스화(혹은, 실제 타입으로 대체)되면 또 다른 배열이나 튜플 타입 세트를 생산할 수 있다.


예를 들면 위의 tail같은 함수를 “천 개의 오버로드로 인한 죽음(death by a thousand overloads)” 이슈 없이 타이핑 할 수 있게 된다.
function tail<T extends any[]>(arr : readonly [any , ...T]) {
  const [_ignored, ...rest] = arr; // 튜플로써 구조 분해 할당
  return rest;
}

const myTuple = [1,2,3,4] as const; // 매개변수 arr을 readonly로 설정하였으므로 as const 추가
const myArray = ["hello" , "world"] as const;

//(3) [2, 3, 4]
const r1 = tail(myTuple);

//(5) [2, 3, 4, 'hello', 'world']
const r2 = tail([...myTuple, ...myArray] as const);

간단히 해석해보자.

참고로 tail함수의 인자로써 받아주게 되는 myTuplemyArrayas const로써 타입 단언을 해준 것은 tail함수의 매개변수로 설정한 arr을 readonly로써 수정 불가하게 해주었기 때문이다.

tail함수의 매개변수 arr을 [any , ...T]로 설정한 것을 볼 수 있을 것이다. 이것은 “튜플”로써 매개변수를 설정해 준 것이고 스프레드 연산자로써 나타낸 ...T를 우린 제네릭 타입으로써 나타낸 <T extends any[]>를 통해 추론을 할 수 있게 된다.

그 후 아래의 두 실행 구문을 통해

//(3) [2, 3, 4]
const r1 = tail(myTuple);

//(5) [2, 3, 4, 'hello', 'world']
const r2 = tail([...myTuple, ...myArray] as const);

인스턴스화되면 새로운 혹은 원하는 배열을 추출할 수 있게 된다.

우린 타입스크립트 4.0 이전의 경우 해당 tail함수를 어떻게 처리했는지 직접 작성해봄으로써 비교해볼 필요가 있다. 사실 이러한 과정이 가장 중요하지 않을까 난 생각한다.

> solve 1) not overloads

function tail(arr : readonly string[] | number[] | (string | number) []) {
  const [_ignored, ...rest] = arr;
  return rest;
}

const onlyString = tail(["hello" , "bye" , "Nice"]);   //string[]
const onlyNum = tail([1,2,3,4]);                    //number[]     
const stringWithNum= tail([1,2,3,4,"hello","bye"]);      //(string | number)[]

console.log(onlyString);   // ['bye', 'Nice']
console.log(onlyNum);   // [2, 3, 4]
console.log(stringWithNum);   // [2, 3, 4, 'hello', 'bye']

별도의 오버로딩을 하지 않았다. tail함수의 매개변수를 표현하는 부분에 유니온 타입으로써 런타입 과정에서 받아올 타입들을 미리 정의해 주었다.

참고로 (string | number)[]로 타입을 선언하면 string[]number[]를 모두 표현할 수 있어 생략이 가능하다. 하지만, 안전하게 선언하고자 직접 표기해준다.


> solve 2) with overloads — 배열 요소의 제한 X

//함수 선언
function tail(arr : number[]) : number[];
function tail(arr : string[]) : string[];
function tail(arr : (string | number)[]) : (string | number)[];

//함수 정의
function tail(arr : readonly any []) {
  const [_ignored, ...rest] = arr;
  return rest;
}

//함수 호출
const onlyNum = tail([1,2,3,4]);  
const onlyString = tail(["hello" , "bye" , "nice"]);
const stringWithNum = tail([1,2,3,4,"hello","bye","nice"]);

console.log(onlyNum);       //[2, 3, 4]
console.log(onlyString);    //['bye', 'nice']
console.log(stringWithNum); //[2, 3, 4, 'hello', 'bye', 'nice']

위의 구문은 tail함수를 오버로딩을 통해 다양한 타입으로써 호출한 로직이다.

함수 선언과정에서 선언해주지 않은 타입에 대해 호출시(예를 들면 boolean[], (string | boolean)[]등 …) 에러를 발생시킨다.

간단히 오버로딩의 형식에 관해 말하자면 아래의 순서를 꼭 준수해야 한다.

1.함수 선언 → 2.함수 정의 → 3.함수 호출

매개변수로 받을 arr에 대해 배열내 요소의 수에 대한 제한은 따로 하지 않고 오로지 arr의 배열 타입에 관해서만 제한해주었다.

즉, 위 구문은 조금 엄격한 튜플 타입이라고 보긴 어려울지도 모른다.


> solve 3) with overloads — 배열의 요소(타입과 갯수 및 순서) 모두 제한

//함수 선언
function tail<A>(arr : [A : number]) : [A];
function tail<A,B>(arr : [A : number , B : number]) : [A,B];
function tail<A,B,C>(arr : [A : number , B : number , C : number]) : [A,B,C];
function tail<A,B>(arr : [A : string, B : string]) : [A,B];
function tail<A,B,C>(arr : [A : string, B : string , C : string]) : [A,B,C];
function tail<A,B,C,D,E>(arr : [A: number , B: number , C: number , D : string , E : string]) : [A,B,C,D,E]

//함수 정의
function tail(arr : readonly any []) {
  const [_ignored, ...rest] = arr;
  return rest;
}

//함수 호출
const print1 = tail([1]);               // []
const print2 = tail([1,2]);             // [2]
const print3 = tail([1,2,3]);           // [2,3]
const print4 = tail(["A" , "B"]);       // ["B"]
const print5 = tail(["A","B","C"]);     // ["B","C"]
const print6 = tail([1,2,3,"A","B"]);   // [2,3,"A","B"];

위는”solve 2)” 에서 조금 더 “튜플” 답게, 각 매개변수 배열의 요소들 타입은 물론이고 갯수 및 순서까지 모두 제한해보았다.

이렇게 하면 조금 더 엄격한 튜플을 구현하게 되지만 , 우리가 앞전에 concat함수의 오버로딩 예시로 보았던 “천 개의 오버로드로 인한 죽음 ”이 만들어진다.

우리는 이렇게 3가지 예시를 통해 타입스크립트 4.0 이전에는 어떻게 튜플 타입 구문을 다뤘는지 알아보았다. 비교적 짧은 예시들이여서 감흥이 없을지 모르겠지만 정말로 오버로드를 천 개(?)만들 수도 있다고 상상해보면 왜 4.0에서 제네릭 타입을 통한 추론을 구현시켰는지 체감이 들 것이다.

조금 뜬금없지만 아래의 현재 사용하는 구문만 익히는 것 보단,

//타입 변수 T에 스프레드 연산자 가능케 함
function tail<T extends any[]>(arr : readonly [any , ...T]) { 
  const [_ignored, ...rest] = arr; 
  return rest;
}

const myTuple = [1,2,3,4] as const; 
const myArray = ["hello" , "world"] as const;

//(3) [2, 3, 4]
const r1 = tail(myTuple);

//(5) [2, 3, 4, 'hello', 'world']
const r2 = tail([...myTuple, ...myArray] as const);

앞전에 수행했던 과정들을 겪으면서 해당 구문들에는 어떠한 문제들이 일어나고 그에 따라 4.0에서 발표한 “스프레드 연산자에서 제네릭 타입 사용”은 어떠한 강점이 있는가를 생각해보는 과정을 거치는것이 중요하다 생각한다.


이처럼 TypeScript 4.0의 두 가지 핵심적 변화 중 첫 번째를 아주 길게 풀어 알아보았고 두 번째 변화를 알아보자.


두 번째 변화 - 나머지 매개변수의 위치

두 번째 변화는 나머지 요소()가 끝뿐만 아니라 튜플의 어느 곳에서도 발생할 수 있다는 것이다.

type Strings = [string, string];
type Numbers = [number , number];

type TwoStrTwoNumBool = [...Strings , ...Numbers , boolean];
// TwoStrTwoNumBool = [string ,string ,number ,number ,boolean];

4.0 이전에는 “Rest element must ne last in a tuple type.” 다음과 같은 오류를 생성했다.

이렇게 가지 동작을 함께 결합하여, concat에 대해 타입이 제대로 정의된 시그니처를 작성할 수 있다.

오버로딩 없이 말이다.

type Arr = readonly any[]; //따로 분리

function concat<T extends Arr, U extends Arr>(arr1 : T , arr2 : U) : [...T , ...U] {
  return [...arr1 , ...arr2];
}

이 기능조금 더 정교한 시나리오에서 빛을 발한다. 예를 들어, 함수의 매개변수를 부분적으로 적용하여 새로운 함수를 반환하는 함수에 관한 시나리오이다.

우리는 해당 함수의 구조를 흔히 “Partial Application”이라 부르기도 한다.


해당 "Partial Application"을 통한 가변 인자 튜플 타입 다루기"는 이번 포스팅에서 작성하고싶지만 글이 루즈해질것을 고려해 다음 포스팅에서 이어서 다루도록 하겠다.


생각정리

이전 포스팅에서 타입스크립트의 기본적 "튜플"의 구조및 쓰임에 관해 알아보았다면 이번 포스팅에선 "가변 인자 튜플 타입"이란 타입스크립트 4.0이후 새롭게 등장한 개념과 함께 조금은 더 심화적이고 유용한 "튜플"에 관해 알아보았다.

위에서도 언급하였지만 작성자 본인의 생각을 잠시 말하자면 타입스크립트 4.0에서 발표한 코드 개념을 바로 익히고 사용하는 것에서 끝을 내기 보단, 4.0 이전에는 해당 코드를 어떤 식으로 풀어나갔고, 어떠한 문제와 불편함이 존재하였는지를 몸소(?) 느끼는 것이 중요하다 생각한다. 우리는 이번 포스팅에서 직접 수많은 오버로딩을 해봄으로써 왜 "제네릭 타입"이 효율적인가를 느껴보았다. 공부를 하는 입장이라면 이러한 과정이 꼭 필요하지 않을까 감히 생각한다.

그럼 여기서 끝내지 말고 다음 포스팅에서 해당 "가변 인자 튜플 타입"을 조금 더 실용적인 예제를 통해 알아보자.

profile
You better cool it off before you burn it out / 티스토리(Kotlin, Android): https://nemoo-dev.tistory.com

0개의 댓글