티스토리 뷰

 아래 글에 이어 작성된 내용입니다.

 

 

 

[TS] TypeScript의 기본 타입

아래 글에 이어 작성된 내용입니다. [TS] TypeScript 시작하기 TypeScript(TS)를 공부한 내용에 대해 기록합니다. 왜 TS를 공부하게 되었는지? 저는 Nest와 TS를 동시에 배우게 된 케이스입니다. 아니 Nest를

munak.tistory.com

 

 

Documentation - Creating Types from Types

An overview of the ways in which you can create more types from existing types.

www.typescriptlang.org

 

제너릭과 조건부타입, infer에 대하여

 


 

서론

타입스크립트의 타입 시스템은 굉장히 강력합니다.

다른 타입을 기반으로 새로운 타입을 만들 수 있기 때문이죠.

 

이번글에서는 이미 있는 타입이나 값에 기반하여 새로운 타입을 만드는 방법을 소개합니다.

 

 

 

제너릭 타입(Generic type)과 타입 파라미터 ⭐

함수에는 매개변수가 있죠.

마찬가지로 타입을 작성하는 것도 타입에 대한 파라미터가 있고, 이를 이용해 타입을 선언할 수 있습니다.

타입 파라미터가 있다면 더 복잡한 타입을 간략하게 표현할 수 있기 때문에 굉장히 유용합니다.

이러한 타입 파라미터를 가진 타입을 제너릭 타입이라고 부릅니다.

type Example = Array<number>;

const e: Example = [1,2,3,4,5];

 

 

 

Documentation - Generics

Types which take parameters

www.typescriptlang.org

 

 

ex.1

왜 제너릭이 유용한지 볼까요?

아래와 같이 인자를 받아 콘솔에 출력하고 그대로 반환하는 `log` 함수를 만들었습니다.

function log(text) {
    console.log(text);
    return text;
}

log(10);   // 10
log("hi"); // hi

 

당신은 함수 호출 시 반환 타입을 알 수 있도록 타입을 명시하려고 합니다.

 

인자로 'string'이 들어왔다면 'string'을 반환 타입으로 나타나야 하고,

'number'가 들어오면 'number'가 반환 타입으로 나타나야 합니다.

 

함수를 재사용하기 위해 유니온 타입을 써볼까요?

function log(text: string | number) {
    console.log(text);
    return text;
}

log(10);   // 10
log("hi"); // hi
log(true); // err

 

인자에 대한 타입 체크는 가능해졌지만, 반환 타입도 유니온으로 나와 반환 타입을 정확히 알 수 없게 되었습니다.

 

 

그럼 아래와 같이 인자에 대해 따로 함수를 정의하는 게 맞을까요? 

function logText(text: string) {
    console.log(text);
    return text;
}
function logNumber(text: number) {
    console.log(text);
    return text;
}

logNumber(10); // 10
logText("hi"); // hi

 

 

 

간단한 예시로도 생산성을 떨어뜨리는 일이란 걸 아실 겁니다.

이럴 때 제너릭을 사용하면 유용합니다.

function log<T>(text: T): T {
    console.log(text);
    return text;
}
log<string>("hi");
log<number>("hi"); // err
log<number>(123);
log<boolean>(123); // err
log<boolean>(true);

 

제너릭은 어떤 타입이 들어올지 모르는 상황에서 함수를 호출하면서 타입을 추론할 수 있도록 도와줍니다!

 

 

ex.2

아래 코드는 아무런 의미 없이, 파라미터를 받아서 그대로 리턴하는 타입입니다. 

// T는 최소한 number이며, T를 인자로 받은 것이 param, 리턴 역시 T이다.

const example = <T extends number>(param: T): T => {
    return param;
};

 

제너릭을 사용해 T는 최소한 `number`이고, 인자와 리턴 타입 또한 `number`인것을 알 수 있습니다. 

 

 

 

 

조건부 타입(Conditional Type)

타입에도 조건이 있습니다.

삼항 연산자와 같이 물음표와 콜론으로 나타내며, 이 경우에는 extends문을 사용해야 합니다.


`A extends B ? C : D`라는 구문이 있다면, `A가 B라면 C고 아니라면 D`라고 해석해야 합니다.

type Example = 1 extends number ? true: false;

const e: Example = true; // false시 에러

 

 

 

제너릭과 조건부 타입의 사용

조건을 쓴다면 당연히 조건에 필요한 인자들이 있을 겁니다.
위 코드처럼 `1 extends number`와 같은 조건식은 우리가 이미 답을 알고 있는 식이죠. 


더 풍부한 타입 표현을 위해선 인자를 받고 인자에 따라 분기처리되는 타입이 있으면 좋을 것 같습니다.

네. 앞서 나온 제네릭을 이용하면 됩니다.

 

// 제너릭이 문자면 true 아니라면 false인 값을 가지는 타입
type Example<T> = T extends string ? true: false;

const e1: Example<'abc'> = true;
const e2: Example<'abc'> = false; // Type 'false' is not assignable to type 'true'.

const e3: Example<123> = true;   // Type 'true' is not assignable to type 'false'.
const e4: Example<123> = false;

 

`Example <T>`은 제가 임의로 만든 타입입니다.  타입파라미터 `T`가 문자라면 `true`값을 가지고 아니라면 `false`를 가지게 됩니다.  

 

 

제너릭 타입의 제약 조건

조건 중에는 제너릭 타입에서만 쓰이는 조건들이 있습니다. 이를 제약 조건이라고 말합니다.

 

제약 조건은 `만약 ~라면`이라는 의미보다는 `최소한 이것`이라는 의미로 해석하는 것이 옳습니다.

제너릭 인자에 제약 조건을 넣게 되면 조건에 해당하지 않는 경우에는 타입 에러가 발생하게 됩니다.

type Example<T extends string> = true;

const e1: Example<string> = true;
const e2: Example<number> = true; // err

 

 

 

조건부 타입과 infer 키워드 ⭐

infer 키워드는 아직 확정되지 않은 가상의 타입을 추론하는 데에 도움을 줍니다.

 

대부분의 infer 키워드는 제너릭의 인자와 함께 쓰이며, 더 풍부한 표현이 가능하게 돕습니다.
T처럼 타입 파라미터에서 무엇이 대입될지 아직 모르는 상태에서 infer 키워드를 사용할 수 있습니다.

type Example<T> = T extends (...param: any[]) => infer R ? R : never;

const add = (a: number, b: number): number => a + b;
const e1: Example<typeof add> = 3; // number

 

위 타입에서 `Example <T>`는 만약 `T`가 함수 타입을 의미한다면, 그 리턴 타입인 `R`로 추론되게 하고 있습니다.

* R은 임의로 부여한 식별자일 뿐, R 자체에는 의미가 없습니다.


만약 위 예제에서 add의 리턴 타입을 string으로 변경한다면 e1의 타입은 반드시 string이어야 합니다.

단 주의해야 할 게 있다면, infer 키워드는 그 조건을 만족하는 최소한의 타입이라는 점입니다.

 

더보기
// type Example 선언
type Example<T> = any

// T가 함수타입을 의미한다면 true 아니라면 never를 반환한다.
type Example<T> = T extends (...param: any[]) => any ? true : never;

// T가 함수 타입을 의미한다면 함수의 반환타입이 무엇인지 아직 모르겠지만 (infer) 그 타입을 반환하고 아니라면 never를 반환한다.
type Example<T> = T extends (...param: any[]) => infer R ? R : never;

 

 

 

이어서 infer에 대해 더 자세히 알아보겠습니다.

 

 

 

string literal 타입에서의 infer 키워드

type Example<T extends string> = T extends `${infer F}${string}` ? F : string;

const e1: Example<"abcde"> = 'a';
const e2: Example<"abcde"> = 'ab'; // err

 

위 코드에서는 `'a'`가 대입된 경우가 아니라면 `e1`은 에러를 뱉습니다.

infer F가 `'a'`라는 문자열 리터럴 타입으로 추론되었기 때문이죠.

 

왜 하필 `'a'`일까요?

F가 `'a'`가 아니라 `'ab'`여도`${infer F}${string}`의 조건을 만족할 텐데 말이죠.

이유는 타입스크립트의 타입 추론이 가장 적절한 타입(Best Common Type)을 추론하기 때문입니다.

infer 키워드가 가장 적절한 타입의 최소한의 범위로 반영된 결과라고 이해하시면 됩니다.

 

 

 

 

배열에서의 infer 키워드

type Example<T> = T extends [infer F, ...infer Rest] ? F : never;

const e: Example<[1, 3]> = 1; // 1만이 대입 가능

 

`T`가 `F`와 나머지 타입 `Rest`로 이루어진 타입이라면 `첫 번째 인자였을 F`로 추론합니다.

그리고 그 외 나머지 배열들은 `…infer Rest`라는 형식을 통해 나머지 배열 타입으로 추론됩니다.

 

즉, 예제 코드에서` Example<[1, 3]>`은 `1`로 추론되어야 합니다.

*이전 코드와 같이 Rest라는 식별자엔 아무런 의미가 없습니다.

 

 

타입 파라미터에서의 infer 키워드

type Example<T> = T extends Array<infer R> ? R : never;

const e: Example<number[]> = 3; // R이 number 타입으로 추론되기 때문에 3 대입 가능.

 

infer 키워드는 홀로 사용될 수도 있지만 제너릭의 인자로도 대입될 수 있습니다.

 

 


 

타입단언 (Type Assertions)

`as` 키워드를 사용하며 타입을 컴파일러에게 직접 알려주는 구문입니다.

 

타입스크립트의 타입추론 시스템을 이용하지 않고 개발자가 직접 입력을 하는 것이죠.

실제 데이터와 무관하게 지정할 수 있기에 잘못 사용 시 잘못된 타입을 추론하는 원인이 됩니다. 

 

따라서 타입단언은 주의해서 사용해야 합니다.

const e1 : any = 123;
const e2 : number = (e1 as string).length; // 컴파일러 : e1은 string 인가 보다! 그럼 length 속성이 있겠지? 오류 없음!

 

 

 

하지만 `as` 키워드를 쓰지 않고는 해결할 수 없는 타입들도 존재합니다.

const add = <T extends number>(
    a: T,
    b: number
): T extends number ? number : "error" => {
    if (typeof a !== "number") {
        return "error"; // 'string' 형식은 'T extends number ? number : "error"' 형식에 할당할 수 없습니다.ts(2322)
    }
    return a + b; //'string' 형식은 'T extends number ? number : "error"' 형식에 할당할 수 없습니다.ts(2322)
};

 

위 코드에서 add 함수는 리턴 문마다 에러가 발생합니다.

add 함수는 타입 T가 number일 경우에는 number를, 아닐 경우에는 ‘error’라는 리터럴 문자를 리턴합니다.

 

우리는 이 코드를 보면서 내부 코드가 리턴 타입에 맞게 정의된 걸 충분히 이해할 수 있을 텐데요.

하지만 컴파일러는 if에 의해 분기 처리된 리턴문을 이해하지 못합니다.

타입스크립트 컴파일러는 코드를 컴파일하는 게 아니라, 어디까지나 타입을 컴파일하는 것이니까요.

 

const add = <T extends number>(
    a: T,
    b: number
): T extends number ? number : "error" => {
    if (typeof a !== "number") {
        return "error" as T extends number ? number : "error";
    }
    return (a + b) as T extends number ? number : "error";
};

 

그래서 우리는 이런 식으로 타입을 수정해야 합니다.

`as`의 사용은 최대한 지양하는 게 맞지만 피치 못할 경우 직접 명시해줘야 할 수도 있습니다.

 

더보기

어쩔 수 없이 as 타입을 써야 할 때의 축약

const add = <T extends number, P extends T extends number ? number : "error">(
    a: T,
    b: number
): T extends number ? number : "error" => {
    if (typeof a !== "number") {
        return "error" as P;
    }
    return (a + b) as P;
};

 

타입 파라미터 P를 추가하여 미리 복잡한 타입 정의를 추가해 둘 수 있습니다.

 

이후 P와 동일한 타입은 as P라고만 쓰면 됩니다.

중복된 as 타입들을 깔끔하게 정리할 수 있지만, 이 역시 문제가 있습니다. 

만의 하나의 경우지만 개발자가 꺽쇠 기호 내부에 P를 직접 명시하려는 경우입니다. 

 

 

 

 

Keyof 타입 연산자

type Example = {
  id: number;
  name: string;
};
type Keys = keyof Example; // 'id' | 'name'

 

keyof 타입연산자는 주어지는 객체의 속성키를 모은 타입으로, 유니온 타입으로 추론됩니다.

이를 사용하면 객체의 특정 속성에 접근하거나 제약을 설정하는데 유용하게 사용할 수 있습니다.

 

Mapped type

Mapped type은 각 속성을 새로운 타입으로 매핑하여 새로운 타입을 만드는 타입스크립트의 기능입니다.

예로 기존 타입의 각 속성을 읽기 전용으로 변경하거나, 속성 값을 변경하여 새로운 타입을 생성할 수 있습니다. 

 

작성법은 아래와 같습니다.

type 새로운-타입 = {
    [key in keyof 기존의-타입]: 각-속성에-새로운-타입;
};

 

* key기존의-타입 각 속성 키를 대표하는 식별자로 무엇으로 하든 상관없지만, 일반적으로 가독성을 위해 보통 Key, key, K, k 등을 사용합니다.

 

 

위와 같이 유니온 타입의 키를 다시 객체 타입으로 변환하는 것 또한 가능합니다.

type EnumType = 'a' | 'b' | 'c' | 'd';
type Example = {
  [K in EnumType]: number;
};

 

위 예시에서 정의된 EnumType의 각각은 `'a', 'b', 'c', 'd'`입니다.
따라서 아래와 같은 타입으로 추론됩니다.

type Example = {
  a: number;
  b: number;
  c: number;
  d: number;
};

 

 

 

 

배열과 Mapped type

자바스크립트에서는 배열도 객체로 취급하죠.

타입 레벨에서도 배열은 객체와 마찬가지로 Mapped type을 사용할 수 있습니다.

type NumberArray = Array<number>;

type StringtoNumbers<T extends Array<number>> = {
    [K in keyof T]: string; // 배열의 각 요소를 문자열로 변환
};

const numbers: NumberArray = [1, 2, 3, 4, 5];
const strings: StringtoNumbers<typeof numbers> = ['1', '2', '3', '4', '5']; // numbers 배열의 각 요소를 문자열로 변환한 배열

 

 

 


감사합니다. 

 

 

공부한 내용을 복습/기록하기 위해 작성한 글이므로 내용에 오류가 있을 수 있습니다.

 

댓글
«   2025/01   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Total
Today
Yesterday