타입스크립트 심화 (2)

타입스크립트 개념 보충하기 2편


Indexed Access

특정 객체 타입을 정의해 놓고 객체 내 특정 프로퍼티의 타입을 필요로 할 경우 사용한다. 주로 복잡하고 큰 타입으로부터 내부 작은 단위의 타입을 얻어낼 때 사용한다.

필요한 프로퍼티의 타입은 선언된 전체 타입의 key를 string 리터럴로 가져오면 된다. 이 때 key의 type으로부터 특정 타입을 가져오는 것이기 때문에 대괄호 내부는 변수(값)를 넣을 수 없다.

interface Post {
  title: string;
  description: string;
  author: {
    id: number;
    name: string;
    age: number;
  }
};
 
function getAuthor (author: Post["author"]) {
  console.log(author.id, author.name, author.age);
};

indexed access 타입을 활용하면 배열 타입으로부터 하나의 요소 타입을 가져올 수 있다.

예시로 Post 타입을 배열로 갖는 PostList 타입이 있다고 할 때 이 PostList 타입으로부터 하나의 Post를 얻기 위해서 indexed access 타입을 사용할 수도 있다. 특정 index로부터 값을 가져와야 하기 때문에 아래와 같이 대괄호 [] 내부에 number 타입을 넣어준다.

type PostList = {
  title: string;
  description: string;
  author: {
    id: number;
    name: string;
    age: number;
  }
}[];
 
const targetPost: PostList[number] = {
  title: '제목',
  description: '설명',
  author: {
    id: 1,
    name: 'yun',
    age: 20
  }
};

튜플을 사용할 경우 각각 인덱스에 대한 타입을 지정할 수 있다.

type TupleType = [string, number, boolean];
type TupStr = TupleType[0];
type TupNum = TupleType[1];
type TupBool = TupleType[2];
type TupErr = TupleType[3];   // ❌ 존재하지 않기 때문에 에러 발생

keyof

객체 프로퍼티의 key를 유니온 타입으로 추출할 때 사용하는 키워드다.

타입스크립트에서 사용하는 기존의 typeof 연산자와도 같이 사용할 수 있는데 특정 변수의 타입을 얻어낼 때 사용된다.

interface Post {
  title: string;
  description: string;
  author: {
    id: number;
    name: string;
    age: number;
  }
};
 
// keyof Post = "title" | "description" | "author"
function getPropertyKey (post: Post, key: keyof Post) {
  return post[key];
};
 
// PostType => { title: string, description: string}
type PostType = typeof post;
 
const post = {
  title: '제목',
  description: '설명'
};

keyof, typeof를 한번에 사용하면 위 getPropertyKey를 다른 방식으로 재정의할 수 있다.

interface Post {
  title: string;
  description: string;
  author: {
    id: number;
    name: string;
    age: number;
  }
};
 
function getPropertyKey (post: Post, key: keyof typeof post) {
  return post[key];
};

Mapped

기존에 정의된 타입을 사용자 입맛에 맞게 다양하게 변환시킬 때 사용한다. interface 키워드에서는 사용할 수 없고 type 별칭에서만 사용할 수 있다.

interface Post {
  title: string;
  description: string;
  author: {
    id: number;
    name: string;
    age: number;
  }
};
 
// Post 타입의 프로퍼티를 모두 선택적 프로퍼티로 변경
type PartialPost = {
  [key in keyof Post]?: Post[key];
}
 
// Post 타입의 프로퍼티 타입을 모두 boolean으로 변경
type BooleanPost = {
  [key in keyof Post]: boolean;
};
 
// Post 타입의 프로퍼티를 모두 readonly로 변경
type ReadonlyPost = {
  readonly [key in keyof Post]: Post[key];
};

템플릿 리터럴

템플릿 리터럴을 조합하여 새로운 타입을 만들 때 사용한다.

type Animal = 'dog' | 'cat';
type Color = 'red' | 'yellow';
 
// red-dog | red-cat | yellow-dog | yellod-cat
type ColoredAnimal = `${Color}-${Animal}`;

단일 조건부 타입

조건에 따라 타입을 결정하는 방법으로 자바스크립트의 삼항 연산자를 사용한다.

type A = {
  a: number
};
 
type B = {
  a: number;
  b: number;
};
 
// 타입 A가 타입 B의 슈퍼 타입일 때 타입 C는 number, 슈퍼 타입이 아닐 때 타입 C는 string
type C = B extends A ? number : string; // number

제네릭과 사용하면 활용도가 높아진다. 아래 코드는 타입 T가 string일 경우 StringNumberSwitch<T> 타입은 number, 타입 T가 number일 경우 StringNumberSwitch<T> 타입은 string이 되도록 하고 있다.

type StringNumberSwitch<T> = T extends string ? number : string;
 
let a: StringNumberSwitch<number>;  // string 타입
let b: StringNumberSwitch<string>;  // number 타입

아래 예제에서는 인수의 타입에 따라 함수의 결과 타입을 다르게 얻는다. 다만 함수 내부에서는 제네릭 T가 unknown 타입이기 때문에 타입 가드로 타입을 좁히기 어렵다.('특정 타입 <- unknown' 불가능 하기 때문) 따라서 이 경우에는 리턴값에 any 타입 단언을 사용하거나 함수 오버로딩을 사용하여 타입을 재정의 해야한다.

any를 사용하면 값 자체의 타입이 any가 되어버리기 때문에 string, undefined 외 다른 타입이 return에 사용될 수 있어 함수 자체에서 타입 에러가 발생하지 않는다. 함수 오버로딩을 사용하면 오버로드 시그니처에서 타입이 다를 경우 타입 에러를 발생시키기 때문에 이 경우 any 보다 함수 오버로딩을 사용하는 것이 좋다.

// 1) any 사용
function removeSpaces<T>(text: T): T extends string ? : string : undefined {
  if (typeof text === 'string') {
    return text.replaceAll(" ", "") as any;
  } else {
    return undefined as any;
  }
}
 
// 2) 함수 오버로딩 사용
function removeSpaces<T>(text: T): T extends string ? : string : undefined
function removeSpaces (text: any) {
  if (typeof text === 'string') {
    return text.replaceAll(" ", "");
  } else {
    return undefined;
  }
}

분산적 조건부 타입

제네릭 T에 단일 타입이 아닌 유니온 타입이 할당될 경우 삼항 연산자는 위와 다르게 작동한다. 최종 타입은 각각의 유니온 타입의 결과들이 하나의 유니온으로 묶이게 된다.

type StringNumberSwitch<T> = T extends string ? number : string;
 
let a: StringNumberSwitch<number>;  // string 타입
let b: StringNumberSwitch<string>;  // number 타입
 
let c: StringNumberSwitch<string | number | boolean>;
 
// 각각의 유니온 타입의 결과들
// StringNumberSwitch<string>   : number
// StringNumberSwitch<number>   : string
// StringNumberSwitch<boolean>  : string
 
// 하나의 유니온으로 묶인다.
// number | string

이를 활용하여 유니온 타입에서 특정 타입만 추출 또는 제거하여 사용할 수 있다.

타입 추출

type Extract<T, U> = T extends U ? T : never;
 
type A = Extract<string | number | boolean, string>;
// 각각의 유니온 타입의 결과들
// Extract<string, string>   : string
// Extract<number, string>   : never
// Extract<boolean, string>  : never
 
// 하나의 유니온으로 묶인다.
// string | never
 
// 결과에 never가 포함되면 제거 => 공집합이기 때문에
// string

타입 제거

type Exclude<T, U> = T extends U ? never : T;
 
type A = Exclude<string | number | boolean, string>;
 
// 각각의 유니온 타입의 결과들
// Exclude<string, string>   : never
// Exclude<number, string>   : number
// Exclude<boolean, string>  : boolean
 
// 하나의 유니온으로 묶인다.
// never | number | boolean
 
// 결과에 never가 포함되면 never 제거
// number | boolean

분산적 조건부 방지

각각의 유니온 타입의 결과를 얻는 것이 아닌, T 자체에 유니온을 넣고 싶을 때는 타입에 대괄호를 씌워준다.

type StringNumberSwitch<T> = [T] extends [string] ? number : string;
 
let a: StringNumberSwitch<string | number | boolean>;  // string

위 예제에서는 [string | number | boolean]은 string의 서브 타입이 아니기 때문에 결과 타입은 항상 string이 된다.

infer

명확하게 정해진 타입이 아닌 특정 타입을 추론하여 얻어낼 때 사용한다.

type ReturnType<T> = T extends () => infer R : R : never;
 
let a: ReturnType<() => number>;  // number
let b: ReturnType<() => string>;  // string
let c: ReturnType<number>;        // never

참고

https://ts.winterlood.com/