TypeScript - TypeScript Study3

 

8. 타입 조작하기**

0) 타입 조작이란

  • 기본 타입이나 별칭 또는 인터페이스로 만든 원래 존재하던 타입들을 상황에 따라 유동적으로 다른 타입으로 변환하는 타입스크립트의 강력한 기능!!


  • 앞에서 배운 제네릭도 함수나 인터페이스, 타입 별칭, 클래스 등에 적용해서 상황에 따라 달라지는 가변적인 타입을 정의할 수 있기 때문에 타입을 조작하는 기능에 포함되지만 개념이 방대하다.



a. 타입 조작하기 4가지

  • 인덱스드 액세스 타입 :
    • 객체, 배열, 튜플 타입으로부터 특정 프로퍼티나 특정 요소의 타입만 추출


  • keyof 연산자 :
    • 객체 타입으로부터 해당 타입 내에 정의된 프로퍼티의 키들을 유니온 타입으로 추출


  • Mapped(맵드) 타입 :
    • 자바스크립트의 맵 함수처럼 기존의 객체 타입을 기반으로 새로운 객체 타입을 만드는 맵드 타입


  • 템플릿 리터럴 타입 :
    • 기존의 스트링 리터럴 타입을 기반으로 정해진 패턴의 문자열만 포함하는 템플릿 리터럴 타입



1) 인덱스드 액세스 타입

  • 인덱스를 이용해 다른 타입내의 특정 프로퍼티의 타입을 추출하는 타입



a. 문제점 발생


  • 매개변수의 타입을 이렇게 정의하면 나중에 Post 타입의 author 프로퍼티의 타입이 다음과 같이 수정되면 매개변수의 타입도 그때 마다 계속 수정해줘야 하는 불편함이 존재
interface Post {
  title: string;
  content: string;
  author: {
    id: number;
    name: string;
  };
}

const post: Post = {
  title: "게시글 제목",
  content: "게시글 본문",
  author: {
    id: 1,
    name: "이정환",
  },
};

function printAuthorInfo(author: { id: number; name: string }) {
  console.log(`${author.id} - ${author.name}`);
}


interface Post {
  title: string;
  content: string;
  author: {
    id: number;
    name: string;
    age: number; // 추가
  };
}

function printAuthorInfo(author: { id: number; name: string, age: number }) {
	// age 프로퍼티도 추가
  console.log(`${author.id} - ${author.name}`);
}

(...)



b. 해결방안

  • 인덱스드 엑세스 타입을 이용해 Post에서 author 프로퍼티의 타입을 추출해 사용하면 편리!!


  • 아래 코드에서 Post["author"]는 Post 타입으로부터 author 프로퍼티의 타입을 추출합니다. 그 결과 author 매개변수의 타입은 {id : number, name: string, age:number}가 된다.

interface Post {
  title: string;
  content: string;
  author: {
    id: number;
    name: string;
    age: number; // 추가
  };
}


function printAuthorInfo(author: Post["author"]) {
  console.log(`${author.id} - ${author.name}`);
}

(...)




c. 주의사항 :

  • a) 인덱스에는 값이 아니라 타입만 들어갈 수 있다. “author”를 문자열 값으로 다른 변수에 저장하고 인덱스로 사용하려고 하면 오류가 발생한다.

const authorKey = "author";

function printAuthorInfo(author: Post[authorKey]) { // ❌
  console.log(`${author.id} - ${author.name}`);
}


  • b) 인덱스에 존재하지 않는 프로퍼티 이름을 쓰면 오류가 발생!!

function printAuthorInfo(author: Post["what"]) { // ❌
  console.log(`${author.id} - ${author.name}`);
}


  • c) 인덱스를 중첩하여 사용할 수도 있다.

interface Post {
  title: string;
  content: string;
  author: {
    id: number;
    name: string;
    age: number;
  };
}


function printAuthorInfo(author: Post["author"]['id']) {
	// author 매개변수의 타입은 number 타입이 됨
  console.log(`${author.id} - ${author.name}`);
}



d. 배열 요소의 타입 추출

  • 초기 배열 타입 선언 :
    • []; 가 포함되어 있다.

type PostList = {
  title: string;
  content: string;
  author: {
    id: number;
    name: string;
    age: number;
  };
}[];


  • 인덱스드 엑세스 타입을 이용해 PostList 배열 타입에서 하나의 요소의 타입만 뽑아올 수 있다.
    • 배열의 요소 타입을 추출할 때에는 인덱스에 number 타입을 넣어주면 된다.

const post: PostList[number] = {
  title: "게시글 제목",
  content: "게시글 본문",
  author: {
    id: 1,
    name: "이정환",
    age: 27,
  },
};


  • 인덱스에 다음과 같이 Number Literal 타입을 넣어도 된다. 숫자와 관계없이 모두 Number 타입을 넣은 것과 동일하게 동작한다.

const post: PostList[0] = {
  title: "게시글 제목",
  content: "게시글 본문",
  author: {
    id: 1,
    name: "이정환",
    age: 27,
  },
}; 




e. 튜플의 요소 타입 추출

  • 튜플의 각 요소들의 타입 또한 인덱스드 엑세스 타입으로 쉽게 추출!!


  • 튜플 타입에 인덱스드 엑세스 타입을 사용할 때, 인덱스에 number 타입을 넣으면 마치 튜플을 배열 처럼 인식해 배열 요소의 타입을 추출

type Tup = [number, string, boolean];

type Tup0 = Tup[0];
// number

type Tup1 = Tup[1];
// string

type Tup2 = Tup[2];
// boolean

type Tup3 = Tup[number]
// number | string | boolean




2) keyof & typeof 연산자


a. Keyof 연산자

  • 객체 타입으로부터 프로퍼티의 모든 key들을 String Literal Union 타입으로 추출하는 연산자


  • 중요** : key의 타입을 name | age로 정의했는데 Person 타입에 새로운 프로퍼티가 추가되거나 수정될 때 마다 이 타입도 계속 바꿔줘야 한다. 이럴 때, Keyof 연산자를 이용하면 좋다!!
interface Person {
  name: string;
  age: number;
  location: string; // 추가
}

function getPropertyKey(person: Person, key: keyof Person) {
  return person[key];
}

const person: Person = {
  name: "이정환",
  age: 27,
};



b. typeof, keyof 함께 사용

  • typeof 연산자는 자바스크립트에서 특정 값의 타입을 문자열로 반환하는 연산자였다. 그러나 타입을 정의할 때, 사용하면 특정 변수의 타입을 추론하는 기능!!

type Person = typeof person;
// 결과
// {name: string, age: number, location:string}

(...)


  • 이런 특징을 이용하면 keyof 연산자를 다음과 같이 사용할 수 있다.(여기는 다시 공부하기!!)

(...)

function getPropertyKey(person: Person, key: keyof typeof person) {
  return person[key];
}

const person: Person = {
  name: "이정환",
  age: 27,
};




3) 맵드 타입

a. 문제 상황

  • 기존의 객체 타입을 기반으로 새로운 객체 타입을 만드는 타입 조작 기능!!

interface User {
  id: number;
  name: string;
  age: number;
}

function fetchUser(): User {
  (...)
}

function updateUser(user: User) {
  // ... 유저 정보 수정 기능
}



  • 그런데, updateUser 함수의 매개변수 타입이 User 타입으로 되어 있어서 수정하고 싶은 프로퍼티만 골라서 보낼 수 없는 상황이다.

interface User {
  id: number;
  name: string;
  age: number;
}

function fetchUser(): User {
  (...)
}

function updateUser(user: User) {
  // ... 유저 정보 수정 기능
}

updateUser({ // ❌
  age: 25
});



  • 어쩔 수 없이 다음과 같이 새로운 타입을 만들어 주어야 한다.


  • 하지만, 아래 코드는 User 타입과 PartialUser 타입이 지금 서로 중복된 프로퍼티를 정의하고 있다. 중복은 언제나 좋지 않다. 따라서, 이럴 때 맵드 타입을 이용하면 좋다!!

interface User {
  id: number;
  name: string;
  age: number;
}

type PartialUser = {
  id?: number;
  name?: string;
  age?: number;
}

(...)

function updateUser(user: PartialUser) {
  // ... 유저 정보 수정 기능
}

updateUser({ // ✅
  age: 25
});



b. 해결 상황

  • 맵드 타입을 이용하면 간단한 한줄의 코드 만으로 중복 없이 기존 타입을 변환할 수 있다.

interface User {
  id: number;
  name: string;
  age: number;
}

type PartialUser = {
  [key in "id" | "name" | "age"]?: User[key];
};

(...)


// 맵드 타입 문법 해석하기
{
  id?: number;
  name?: string;
  age?: number;
}



c. keyof 업그레이드**


interface User {
  id: number;
  name: string;
  age: number;
}

type PartialUser = {
  [key in keyof User]?: User[key];
};

(...)



d. 읽기 전용 프토퍼티(맵드 타입)

  • 맵드 타입을 이용해 모든 프로퍼티가 읽기 전용 프로퍼티가 된 타입!!

interface User {
  id: number;
  name: string;
  age: number;
}

type PartialUser = {
  [key in keyof User]?: User[key];
};

type ReadonlyUser = {
  readonly [key in keyof User]: User[key];
};

(...)



4) 템플릿 리터럴 타입

  • 템플릿 리터럴을 이용해 특정 패턴을 갖는 String 타입을 만드는 기능!!(가장 단순한 기능)

type Color = "red" | "black" | "green";
type Animal = "dog" | "cat" | "chicken";

type ColoredAnimal = `red-dog` | 'red-cat' | 'red-chicken' | 'black-dog' ... ;



  • ColorAnimal 타입에 String Literal 타입이 추가되어 경우의 수가 많아질 수록 ColoredAnimal 타입에 추가해야하는 타입이 점점 많아지게 된다. 이럴 때, 템플릿 리터럴 타입을 이용하면 좋다.
type ColoredAnimal = `${Color}-${Animal}`;



9. 조건부 타입**


0) 조건부 타입

  • extends와 삼항 연산자를 이용해 조건에 따라 각각 다른 타입을 정의하도록 돕는 문법

type A = number extends string ? number : string;


  • 조건부 타입은 제네릭과 함께 사용할 때, 그 위력이 극대화!!
    • 아래 예시처럼 타입변수에 Number 타입이 할당되면 String 타입을 반환하고 그렇지 않다면 Number 타입을 반환하는 조건부 타입

type StringNumberSwitch<T> = T extends number ? string : number;

let varA: StringNumberSwitch<number>;
// string

let varB: StringNumberSwitch<string>;
// number




a. 조건부 타입 예시


  • 매개변수로 String 타입의 값을 제공받아 공백을 제거한 다음 반환하는 함수

function removeSpaces(text: string) {
  return text.replaceAll(" ", "");
}

let result = removeSpaces("hi im winterlood");


  • 함수 내부에서 text의 타입은 String이 아닐 수 있기 때문에 오류가 발생해서 타입을 좁혀 사용해야 한다.

function removeSpaces(text: string | undefined | null) {
  if (typeof text === "string") {
    return text.replaceAll(" ", "");
  } else {
    return undefined;
  }
} 

let result = removeSpaces("hi im winterlood");
// string | undefined


  • 조건부 타입을 이용해 인수로 전달된 값의 타입이 String이면 반환값 타입도 String이고 아니라면 반환값 타입을 undefined 으로 만들어 주면 된다.
    • 타입변수 T를 추가하고 매개변수의 타입을 T로 정의한 다음 반환값의 타입을 T extends string ? string : undefined 으로 수정

function removeSpaces<T>(text: T): T extends string ? string : undefined {
  if (typeof text === "string") {
    return text.replaceAll(" ", ""); // ❌
  } else {
    return undefined; // ❌
  }
} 

let result = removeSpaces("hi im winterlood");
// string

let result2 = removeSpaces(undefined);
// undefined


  • 그런데, 다음 코드에서 any로 단언하는것은 별로 좋지 못하다고 배운 적 있다. 첫 번째 return 문에서 string이 아닌 타입의 값을 반환 해도 오류를 감지하지 못한다.
function removeSpaces<T>(text: T): T extends string ? string : undefined {
  if (typeof text === "string") {
    return 0 as any; // 문제 감지 못함
  } else {
    return undefined as any;
  }
}

let result = removeSpaces("hi im winterlood");
// string

let result2 = removeSpaces(undefined);
// undefined


  • 그래서, 타입 단언보다는 함수 오버로딩을 이용하는게 더 좋다. 오버로드 시그니쳐의 조건부 타입은 구현 시그니쳐 내부에서 추론이 가능!!
function removeSpaces<T>(text: T): T extends string ? string : undefined;
function removeSpaces(text: any) {
  if (typeof text === "string") {
    return text.replaceAll(" ", "");
  } else {
    return undefined;
  }
}

let result = removeSpaces("hi im winterlood");
// string

let result2 = removeSpaces(undefined);
// undefined



1) 분산적인 조건부 타입

  • 변수 a의 타입은 조건식이 참이되어 string으로 정의되고 변수 b의 타입은 조건식이 거짓이 되어 number 타입으로 정의

type StringNumberSwitch<T> = T extends number ? string : number;

let a: StringNumberSwitch<number>;

let b: StringNumberSwitch<string>;


  • 타입 변수에 Union 타입을 할당


  • 변수 c는 string | number 타입으로 정의
    • 조건부 타입의 타입 변수에 Union 타입을 할당하면 분산적인 조건부 타입으로 조건부 타입이 업그레이드 되기 때문!!


  • 타입 변수에 할당한 Union 타입 내부의 모든 타입이 분리됩니다. 따라서 StringNuberSwitch<number | string> 타입은 다음과 같이 분산됩니다.
    • StringNumberSwitch<number>
    • StringNumberSwitch<string>


  • 그리고 다음으로 분산된 각 타입의 결과를 모아 다시 Union 타입으로 묶습니다.
    • 결과 : number | string

type StringNumberSwitch<T> = T extends number ? string : number;

(...)

let c: StringNumberSwitch<number | string>;
// string | number




a. Exclude 조건부 타입

  • 분산적인 조건부 타입의 특징을 이용하면 매우 다양한 타입을 정의!!
    • Union 타입으로부터 특정 타입만 제거하는 Exclude(제외하다) 타입!!

a) 분석 과정

  • (a). Union 타입이 분리된다.
    • Exclude<number, string>
    • Exclude<string, string>
    • Exclude<boolean, string>


  • (b). 각 분리된 타입을 모두 계산한다.
    • T = number, U = string 일 때 number extends string 은 거짓이므로 결과는 number
    • T = string, U = string 일 때 string extends string 은 참이므로 결과는 never
    • T = boolean, U = string 일 때 boolean extends string 은 거짓이므로 결과는 boolean


  • (c). 계산된 타입들을 모두 Union으로 묶는다
    • 결과 : number | never | boolean


  • (d). 최종적으로 타입 A는 number | boolean 타입

type Exclude<T, U> = T extends U ? never : T;

type A = Exclude<number | string | boolean, string>;




2) infer

  • 조건부 타입 내에서 특정 타입을 추론하는 문법
    • 특정 함수 타입에서 반환값의 타입만 추출하는 특수한 조건부 타입인 ReturnType을 만들 때, 이용할 수 있다.
    • T extends () => infer R에서 infer R은 이 조건식을 참이 되도록 만들 수 있는 최적의 R 타입을 추론하라는 의미!!



a. infer 분석

  • (a). 타입 변수 T에 함수 타입 FuncA가 할당된다.


  • (b). T는 () ⇒ string 이 된다.


  • (c). 조건부 타입의 조건식은 다음 형태가 된다 () ⇒ string extends () ⇒ infer R ? R : never


  • (d). 조건식을 참으로 만드는 R 타입을 추론한다. 그 결과 R은 string이 된다.


  • (e). 추론이 가능하면 이 조건식을 참으로 판단한다. 따라서, 결과는 string이 된다.
    • 추론이 불가능하다면 조건식을 거짓으로 판단!

type ReturnType<T> = T extends () => infer R ? R : never;

type FuncA = () => string;

type FuncB = () => number;

type A = ReturnType<FuncA>;
// string

type B = ReturnType<FuncB>;
// number

type C = ReturnType<number>;
// 조건식을 만족하는 R추론 불가능
// never



  • Promise의 resolve 타입을 infer를 이용해 추출하는 예!!

type PromiseUnpack<T> = T extends Promise<infer R> ? R : never;
// 1. T는 프로미스 타입이어야 한다.
// 2. 프로미스 타입의 결과값 타입을 반환해야 한다.

type PromiseA = PromiseUnpack<Promise<number>>;
// number

type PromiseB = PromiseUnpack<Promise<string>>;
// string




10. 유틸리티 타입


0. 유틸리티 타입 소개

  • 타입스크립트가 자체적으로 제공하는 특수한 타입들이다.


  • 지금까지 배웠던 제네릭, 맵드 타입, 조건부 타입 등의 타입 조작 기능을 이용해 실무에서 자주 사용되는 유용한 타입들을 모아 놓은 것!!




a. 유틸리티 타입 예시


  • Readonly<T>와 같은 유틸리티 타입을 이용해 특정 객체 타입의 모든 프로퍼티를 읽기 전용 프로퍼티로 변환!

interface Person {
  name : string;
  age : number;
}

const person : Readonly<Person> ={
  name : "이정환",
  age : 27
}

person.name = ''
// ❌ name은 Readonly 프로퍼티입니다.


  • Partial<T> 유틸리티 타입을 이용해 특정 객체 타입의 모든 프로퍼티를 선택적 프로퍼티로 변환하는 것도 가능!

interface Person {
  name: string;
  age: number;
}

const person: Partial<Person> = {
  name: "이정환",
};



1) Partial, Required, Readonly


a. Partial

  • Partial 타입은 타입 변수 T로 전달한 객체 타입의 모든 프로퍼티를 다 선택적 프로퍼티로 변환

interface Post {
  title: string;
  tags: string[];
  content: string;
  thumbnailURL?: string;
}

const draft: Partial<Post> = {
  title: "제목 나중에 짓자",
  content: "초안...",
};



a) Partial 구현하기

  • T에 할당된 객체 타입의 모든 프로퍼티를 선택적 프로퍼티로 바꿔줘야 한다. 기존 객체 타입을 다른 타입으로 변환하는 타입은 맵드 타입이라서 맵드 타입을 다음과 같이 수정!!

type Partial<T> = {
  [key in keyof T]?: T[key];
};



b. Required

  • Required는 Post 타입의 모든 프로퍼티가 필수 프로퍼티로 변환된 객체 타입이다. 따라서, 위 코드처럼 thumbnailURL 프로퍼티를 생략하면 이제 오류가 발생하게 된다.
interface Post {
  title: string;
  tags: string[];
  content: string;
  thumbnailURL?: string;
}

(...)

const withThumbnailPost: Required<Post> = { // ❌
  title: "한입 타스 후기",
  tags: ["ts"],
  content: "",
  // thumbnailURL: "https://...",
};



a) Required 타입 구현하기

  • 모든 프로퍼티를 필수 프로퍼티로 만든다는 말은 반대로 바꿔보면 모든 프로퍼티에서 ‘선택적’ 이라는 기능을 제거하는 것과 같다.


  • 따라서 다음과 같이 -?를 프로퍼티 이름 뒤에 붙여주면 된다.
type Required<T> = {
  [key in keyof T]-?: T[key];
};



c. Readonly

  • Readonly<Post>는 Post 타입의 모든 프로퍼티를 readonly(읽기 전용) 프로퍼티로 변환한다.


  • 따라서, 점표기법을 이용해 특정 프로퍼티의 값을 수정하려고 하면 오류를 발생시킨다.
interface Post {
  title: string;
  tags: string[];
  content: string;
  thumbnailURL?: string;
}

(...)

const readonlyPost: Readonly<Post> = {
  title: "보호된 게시글입니다.",
  tags: [],
  content: "",
};

readonlyPost.content = '해킹당함'; // ❌



a) Readonly 구현하기

type Readonly<T> = {
  readonly [key in keyof T]: T[key];
};



2) Record, Pick, Omit


a. Record

  • Record 타입은 K에는 “large” | “medium” | “small”이 할당되었으므로 large, medium, small 프로퍼티가 있는 객체 타입을 정의한다.


  • 그리고 각 프로퍼티 value의 타입은 V에 할당한 { url : stirng } 이 된다.

type Thumbnail = Record<
  "large" | "medium" | "small",
  { url: string }
>;


a) Record 직접 구현

  • Record 타입은 다음과 같이 구현할 수 있다.
type Record<K extends keyof any, V> = {
  [key in K]: V;
};



b. Pick

  • 특정 객체 타입으로부터 특정 프로퍼티 만을 골라내는 그런 타입!!
interface Post {
  title: string;
  tags: string[];
  content: string;
  thumbnailURL?: string;
}

(...)

const legacyPost: Pick<Post, "title" | "content"> = {
  title: "",
  content: "",
};
// 추출된 타입 : { title : string; content : string }


a) Pick 직접 구현

  • T로 부터 K 프로퍼티만 뽑아낸 객체 타입을 만들어야 하므로 일단 맵드 타입으로 정의!!
type Pick<T, K> = {
  [key in K]: T[key];
};


  • K가 T의 key로만 이루어진 String Literal Union 타입임을 보장
type Pick<T, K extends keyof T> = {
  [key in K]: T[key];
};



c. Omit

  • 특정 객체 타입으로부터 특정 프로퍼티 만을 제거하는 타입


  • Omit을 이용해 Post 타입으로부터 title 프로퍼티를 제거한 타입으로 변수의 타입을 정의해 주면 된다.
const noTitlePost: Omit<Post, "title"> = {
  content: "",
  tags: [],
  thumbnailURL: "",
};


a) Omit 직접 구현

  • keyof T‘title’ | ‘content’ | ‘tags’ | ‘thumbnailURL’이므로 Pick<T, Exclude<keyof T, K>>Pick<Post, Exclude<'title' | 'content' | 'tags' | 'thumbnailURL' , 'title>> 이 된다.


  • Pick<Post, 'content' | 'tags' | 'thumbnailURL'> : 그럼 결과는 Post에서 content, tags, thubmnailURL 프로퍼티만 존재하는 객체 타입이 된다. 따라서, K에 전달한 ‘title’이 제거된 타입을 얻을 수 있다.
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;



3) Exclude, Extract, ReturnType


a. Exclude

  • Exclude 타입은 다음과 같이 T로부터 U를 제거하는 타입!!
type A = Exclude<string | boolean, string>;
// boolean


a) Exclude 직접 구현


type Exlcude<T, U> = T extends U ? never : T;



b. Extract

  • Extract 타입은 다음과 같이 T로 부터 U를 추출하는 타입!!
type B = Extract<string | boolean, boolean>;
// boolean


a) Extract 직접 구현

type Extract<T, U> = T extends U ? T : never;



c. ReturnType

  • ReturnType은 타입변수 T에 할당된 함수 타입의 반환값 타입을 추출하는 타입
type ReturnType<T extends (...args: any) => any> = T extends (
  ...agrs: any
) => infer R
  ? R
  : never;

function funcA() {
  return "hello";
}

function funcB() {
  return 10;
}

type ReturnA = ReturnType<typeof funcA>;
// string

type ReturnB = ReturnType<typeof funcB>;
// number