본 포스팅은 한 입 크기로 잘라먹는 타입스크립트 강의를 참고하여 작성했습니다. 문제가 될 소지가 있다면 댓글로 알려주세요. 바로 수정하겠습니다.
타입을 조작하는 도구에는 제네릭 인덱스드 액세스 타입 keyof 연산자 Mapped(맵드) 타입 템플릿 리터럴 타입 조건부 타입 이 있다.
타입을 조작 한다는 것은 기존 타입을 기반으로 새로운 타입을 만들거나, 타입에 대한 어떤 조건이나 연산을 적용해 타입의 형태를 바꾸는 것을 말한다.
하나씩 살펴보자.
함수, 클래스, 인터페이스 등을 선언할 때, 타입을 고정하지 않고, 파라미터처럼 받아서 사용할 수 있게 하는 문법이다.
제네릭이 타입 조작 방법에 해당하지 않는다고 생각할 수도 있는데, 문맥에 따라 타입이 동적으로 결정되기 때문에 타입 조작에 해당한다.
class List<T> {
constructor(private list: T[]) {}
push(data: T) {
this.list.push(data);
}
pop() {
return this.list.pop();
}
print() {
console.log(this.list);
}
}
const numberList = new List([1, 2, 3]);
numberList.pop();
numberList.push(4);
numberList.print();
numberList.push('2'); // error!!
const stringList = new List(["1", "2", "3"]);
stringList.pop();
객체, 배열, 튜플 타입에서 특정 프로퍼티 혹은 요소의 타입을 추출하는 타입이다.
복합하고 큰 타입에서, 필요한 작은 타입으로 추출하여 쓸 수 있다.
예를 들어, 다음과 같은 코드가 있다고 해보자.
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.name} - ${author.id}`);
}
printAuthorInfo 함수에서 매개변수 author 에 대한 타입을 {id : number; name: string} 의 형태로 정의했다.
그런데 만약, author 의 내부 구조가 바뀐다면, 일일이 수정해줘야 하는 상황이 발생한다.
이런 상황에서, 인덱스드 액세스 타입을 통해 해결할 수 있다.
function printAuthorInfo(author: Post['author']) {
console.log(`${author.name} - ${author.id}`);
}
Post['author'] 과 같이 타입을 지정해주면, Post 라는 타입의 author 속성의 타입이 추출된다.
그래서 이후 author 구조가 변경되어도, 알아서 타입이 추출되기 때문에 따로 수정해줄 필요가 없게된다.
여기서 주의할 점은,Post['author']에서 'author' 은 리터럴 타입이어야 하며, 변수 값은 사용할 수 없다는 것이다.
즉, 아래와 같이 코드를 작성하면 오류가 발생한다.
const author = 'author'
type Author = Post[author]; // error!!
인덱스 부분에는 반드시 리터럴 타입이 와야 하며, 일반 변수를 사용하면 오류가 발생하니 주의하자.
프로퍼티의 키들을 모두 스트링 리터럴 유니온 타입으로 추출하는 연산자이다.
아래 코드를 보자.
interface Person {
name: string;
age: number;
}
const person: Person = {
name: "홍길동",
age: 26,
};
function getPropertyKey(person: Person, key: "name" | "age") {
return person[key];
}
getPropertyKey(person, "name"); // 홍길동
getPropertyKey() 함수는 Person 타입 객체와 해당 객체의 키중 하나를 받아, 해당 키에 해당하는 값을 반환하는 함수이다.
만약, Person 타입에 키가 많이 추가 되었다면? 혹은, 기존 타입이 수정되어야 한다면?
매개변수에 일일이 추가해주거나 수정해줘야 하는 번거로움이 생길 것이다.
이럴 때, keyof 연산자를 사용하면 해결할 수 있다.
function getPropertyKey(person: Person, key: keyof Person) {
return person[key];
}
두 번째 함수는 keyof 연산자를 사용해 타입 변경에 유연하게 대응할 수 있다.
기존의 객체 타입으로부터 새로운 객체 타입을 만드는 타입이다.
다음 코드를 보자.
interface User {
id: number;
name: string;
age: number;
}
interface PartialUser {
id?: number;
name?: string;
age?: number;
}
// 한명의 유저 정보를 업데이트
function updateUser(user: PartialUser) {
// update
}
updateUser({
// id :1,
// name: :'이도현'
age: 25,
});
코드를 살펴보면, User 타입이 존재하고, 해당 타입의 객체를 update 할 수 있는 함수가 존재한다.
그런데, update는 부분적으로 이루어질 수 있기에, 기존 User 의 속성이 전부 선택적 프로퍼티 인 PartialUser을 새로 정의했다.
User 타입의 길이가 길어질 수록, 두 배로 코드가 늘어나기에 비효율적으로 보인다.
이를 Mapped 타입 을 통해 해결할 수 있다.
type PartialUser = {
[key in "id" | "name" | "age"]?: User[key];
}
코드가 생소할 수 있는데 풀이하면 다음과 같다.
[key in "id" | "name" | "age"]? : key가 'id', 'name', 'age' 일 수 있고, 선택적 프로퍼티임.
User[key] : 해당 key에 대응하는 속성의 타입을 의미한다.
예를 들어 key 가 'id' 라면, User['id'] 인 number가 된다.
즉, key 에 어떤 값이 들어갈 수 있는지를 표현하고, 그것을 User[key] 를 통해 value를 정의한 것이다.
앞서서 "id" | "name" | "age" 와 같은 스트링 리터럴 유니온 타입은 keyof 를 통해 대체 가능하다고 했다. 만약, 모든 key 에 대해서 새로운 타입을 재정의할 것이라면 아래와 같이 코드를 작성하면 더욱 간단하게 표현 가능하다.
type PartialUser = {
[key in keyof User]?: User[key];
}
스트링 리터럴 타입을 기반으로, 정해진 패턴의 문자열만 포함하는 타입이다.
type Color = "red" | "black" | "green";
type Animal = "dog" | "cat" | "chicken";
// type ColoredAnimal = 'red-dog' | 'red-cat' | ...
두 타입이 있고, 두 타입을 합친 패턴의 새로운 타입인 ColoredAnimal 을 만들고자 하는 상황이라고 가정하자.
그럼 아래와 같이 작성 가능하다.
type ColoredAnimal = `${Color}-${Animal}`;
// -> type ColoredAnimal = "red-dog" | "red-cat" | "red-chicken" | "black-dog" | "black-cat" | "black-chicken" | "green-dog" | "green-cat" | "green-chicken"
조건부 타입 은 조건에 따라 타입을 다르게 지정할 수 있는 문법이다.
type A = number extends string ? string : number;
해당 예제는 number 타입이 string 타입을 확장한다면 A의 타입을 string 으로, 그렇지 않다면 number 타입으로 설정하라는 의미이다.
제네릭과 함께 사용한다면, 더 유용하게 사용할 수 있다.
만약, 매개변수로 text 를 입력받고, 해당 값이 문자열이면 공백을 제거하여 반환하는 함수를 만든다고 가정하자.
function removeSpaces<T>(text: T): T extends string ? string : undefined {
if(typeof text === "string") {
return text.replaceAll(" ", "");
} else {
return undefined;
}
}
이 코드는 제네릭을 통해, T가 string 타입이라면 반환 타입도 string 으로, 그렇지 않다면 undefined로 지정하려고 했다.
하지만, 함수 구현부에서는 조건부 타입의 결과를 컴파일 타임에 알 수 없기 때문에 타입 오류가 발생한다.
각 반환문에서 as any 타입 단언을 통해 해결할 수도 있겠지만, 타입 안전성을 해칠 우려가 있다.
이럴 때 오버로드 시그니처 를 사용하면, 구현부에서도 조건부 타입의 결과를 추론할 수 있게 할 수 있어 유용하다.
function removeSpaces<T>(text: T): T extends string ? string : undefined;
function removeSpaces(text: any) {
if (typeof text === "string") {
return text.replaceAll(" ", "");
} else {
return undefined;
}
}
조건부 타입을 유니온 타입과 함께 사용했을 때, 각 유니온 멤버에 분산적으로 적용되게 하는 문법이다.
type StringNumberSwitch<T> = T extends number ? string : number;
let c: StringNumberSwitch<number | string>; // c : number | string;
얼핏보면 number | string 타입이 number 타입을 extends 하지 않기 때문에, c의 타입은 number 타입이라고 생각할 수 있다.
조건부 타입을 유니온 타입과 함께 사용하면 유니온 타입 그 자체로 타입 변수에 할당되지 않고, 각각 분리되어 할당되게 된다.
다시 말해 아래처럼 동작하는 것이다.
let c : StringNumberSwitch<number> | StringNumberSwitch<string>
다른 예시를 하나 더 살펴보자.
let d: StringNumberSwitch<boolean | number | string>;
해당 예시를 뜯어서 살펴보면 다음과 같다.
1단계
StringNumberSwitch<boolean> |
StringNumberSwitch<number> |
StringNumberSwitch<string>
2단계
number |
string |
number
결과
number | string
// never 타입은 공집합 타입이기 때문에 유니온 타입에서 사라짐
infer 는 inference(추론하다) 의 약어로, 조건부 타입 내에서 타입을 임시 변수로 추론할 때 사용합니다.
만약, 특정 함수에서 반환하는 타입을 추론해야 하는 상황이 있다고 가정해보자.
type FuncA = () => string;
type FuncB = () => number;
type ReturnType<T> = T extends () => infer R ? R : never;
type C = ReturnType<FuncA>; // C : string
type D = ReturnType<FuncB>; // D : number
infer R은 조건이 참일 경우, 함수 반환 타입을 R로 추론해 사용할 수 있도록 한다.