kakasoo

type Push = <T extends any[], value> = […T, value] 본문

프로그래밍/TypeScript

type Push = <T extends any[], value> = […T, value]

카카수(kakasoo) 2023. 1. 20. 23:53
반응형

타입스크립트에서 제너릭으로 받은 타입 T, P… 등등은 일반적으로 타입 파라미터라고 부른다.

그 이유는, 제너릭이 타입을 매개변수로 받아 타입에 따른 클래스와 함수를 정의하기 때문이다.

이렇게 제너릭을 활용하면 코드를 추상적으로 작성 가능해져 타입 별로 코드를 구현할 필요가 없게 된다.

 

type Push<T extends any[], value> = [...T, value];

이 Push 라고 하는 타입은 T와 value 라는 타입 파라미터를 받는다.

이번에 구현할 Push 타입은 Array.prototype.push와 같이, 타입에 새로운 타입인 value를 확장하는 타입이다.

기존의 push 메서드는 push 연산 이후의 배열이 어떤 상태인지, 타입 레벨에서의 추론이 동작하지 않는다.

첫번째 타입 파라미터 T는 any[]를 확장하며, value는 any 타입이기 때문에 아무것도 확장하지 않는다.

any 타입인 경우, 이처럼 아무것도 작성하지 않으면 자동으로 any 타입으로 추론된다.

 

function push (arr: number[], val: number) {
      arr.push(value);
      return arr;
}

기존 push 메서드를 대체하기 전에, 동일한 동작을 수행할 push 함수를 구현했다.

이렇게 push 라고 하는 함수를 정의할 때, 이 함수를 이제 제너릭을 활용해 Push 타입을 반환하게 해야 한다.

그래야 number 같이 원시적인 값이 아니라, 함수 실행 전 대입한 값들로 이루어진 타입을 전달할 수 있다.

 

function push<T extends number[], value>(arr: T, val: value): number[] {
    arr.push(value);
    return arr;
}

T, value 타입을 각각 파라미터인 arr, val에 대입했다.

그리고 number[]를 확장한 T 타입을 가진 arr에 두번째 인자로 받은 value를 넣은 배열을 반환하게 했다.

하지만 리턴 타입은 여전히 number[]로, 타입이 완벽하게 추론되지는 않는다.

내가 원하는 것은 이 함수가 동작할 때, arr의 결과 값이 바로 예측 가능하게 되는 것이다.

 

type Push<T extends readonly any[], value> = [...T, value];

function push<T extends any[], value>(arr: T, val: value): Push<T, value> {
    arr.push(value);
    return arr; // compile error!
}

그러니 결과 타입으로 Push type을 지정해주고, Push 타입의 타입 파라미터로는 제너릭들을 넣어준다.

Push<T, value> 타입은 즉 […T, value] 타입이기 때문에 return 부분에서 빨간색 밑줄이 발생한다.

이걸로 우리가 원하던 함수 구현에서 어느 부분이 잘못되었는지를 바로 파악할 수 있다!

 

이유는, 기존의 Array.prototype.push 메서드를 실행하는 것이 원본 arr의 타입을 수정하지 않기 때문이다.

arr의 타입은 number[] 였고, Array.prototype.push 메서드를 호출해도 여전히 number[]이기 때문이다.

number[] 타입은 […T, value] 타입에 들어갈 수 없다.

따라서 반환 타입에 맞게 함수의 결과 값을 수정해준다.

 

type Push<T extends readonly any[], value> = [...T, value];

function push<T extends any[], value>(arr: T, val: value): Push<T, value> {
    return [...arr, val];
}

이제 컴파일 에러가 없어졌고, 따라서 코드에도 빨간색 밑줄이 생기지 않는다.

 

const arr = [1,2,3];
const result = push(arr, 4);

이 함수는 올바르게 동작할까?

arr를 1,2,3을 가진 배열로 정의하고 push 함수에 4라는 인자 값과 함께 실행시켜본다.

그러면 result의 타입은 어떻게 될까?

 

type Answer = typeof result; // [...number[], number]

하지만 아직 완벽한 타입이 추론되진 않는데, 이는 함수 구현의 문제가 아니라 파라미터의 문제다.

우리가 push 메서드에 전달한 arr의 타입이 [1,2,3]이 아니라 number[] 였기 때문에 발생한 문제다.

 

const arr: [1, 2, 3] = [1, 2, 3];
const result = push(arr, 4);

type Answer = typeof result; // [1, 2, 3, number]

만약 arr 의 타입을 저렇게 명시해준다면, 이제는 올바르게 추론되는 걸 확인할 수 있다.

마찬가지로 두번째 파라미터도 이렇게 변경해줄 수 있다.

 

const arr: [1, 2, 3] = [1, 2, 3];
const result = push(arr, 4 as const);

type Answer = typeof result; // [1, 2, 3, 4]

하지만 이렇게 타입 레벨에서 작성되었다고 해도, 결국 타입스크립트는 런타임에서의 결과를 보장하진 못한다.

왜냐하면 아래와 같은 연산을 통해, 타입을 무시하는 행위가 가능하기 때문이다.

 

const arr: [1, 2, 3] = [1, 2, 3];
const result = push(arr, 4 as const); // result: [1, 2, 3, 4]

arr.push(3);
type Answer = typeof result; // typeof result: [1, 2, 3, 4]

const wtf: Answer = [...arr, 4];

기존의 Array.prototype.push 메서드는 원본 arr이 타입에 대해서 일절 관여하지 않는 형식으로 짜여 있다.

따라서 arr의 타입이 [1, 2, 3] 이라고 명시된 배열이라고 한들, 결국 push 메서드는 잘만 동작한다.

문제는 이 다음이다.

Answer는 여전히 push 메서드를 호출하기 전의 result의 타입을 가리키기 때문에 [1, 2, 3, 4]로 지정된다.

따라서 wtf에 Answer라는 타입을 지정한들, 말도 안 되는 값에 대한 대입 연산을 막을 방법이 없다.

이걸 막기 위해서는 arr 식별자로 정의한 변수의 타입을 readonly ( 읽기만 가능한 타입 ) 로 지정해야 한다.

 

const arr: readonly [1, 2, 3] = [1, 2, 3];
function push<T extends readonly any[], value>(arr: T, val: value): Push<T, value> {
    return [...arr, val];
}

const arr: readonly [1, 2, 3] = [1, 2, 3];
const result = push(arr, 4 as const); // compile error를 해결하기 위해 타입 파라미터 수정

readonly로 지정한 타입은, readonly type이기 때문에 push 함수의 타입 파라미터 T도 수정해야 한다.

T는 이제 T extends readonly any[] 라는 타입으로 정의된다.

그러면 result에 push 함수 호출 결과에도 아무런 문제가 없고,

 

arr.push(3); // compile error: 'readonly [1, 2, 3]' 형식에 'push' 속성이 없습니다.ts(2339)

타입을 망가뜨릴 위험이 있던 push 메서드를 언어 레벨에서 제한하기 때문에 더 안전하게 타입 지정이 가능하다.

 

type Push<T extends any[], value> = [...T, value];

function push<T extends readonly any[], value>(arr: T, val: value): Push<T, value> {
    return [...arr, val];
}

const arr: readonly [1, 2, 3] = [1, 2, 3];
const result = push(arr, 4 as const);

// arr.push(3); // compile error로 인해 이 코드는 사용할 수 없다!
type Answer = typeof result;

최종적인 코드는 이렇다.

readonly 타입 대신에 아래처럼도 가능하다.

 

const arr: [1, 2, 3] = [1, 2, 3] as const;
반응형