Generic Types(제네릭 타입)
Generic Types(제네릭 타입)은 다양한 타입의 인자를 받아들이는 타입을 정의할 수 있게 해준다. 이를 통해 코드의 재사용성을 높이고 유연성을 확보할 수 있다.
예를 들어, Array는 여러 타입의 원소를 담을 수 있는 배열을 생성할 수 있다. 하지만 Array는 한 가지 타입만을 받아들이는 것이 아니라, string, number, boolean 등 다양한 타입의 원소를 담을 수 있다. 이런 경우, Array를 사용하기 위해서는 Array<string>, Array<number>와 같은 형태로 타입 인자를 전달해야 한다.
function identity<T>(arg: T): T {
return arg;
}
let output1 = identity<string>("myString"); // 타입 인자로 string을 전달한다.
let output2 = identity<number>(42); // 타입 인자로 number를 전달한다.
위 코드에서 identity 함수는 타입 인자 T를 받아들이고, 그 값을 그대로 반환하는 함수다. 함수 호출 시에 타입 인자를 전달하면, 함수의 반환값이 그 타입으로 설정된다.
identity<string>("myString")에서는 string을 타입 인자로 전달하므로, 함수는 string 타입의 값을 반환한다. identity<number>(42)에서는 number를 타입 인자로 전달하므로, 함수는 number 타입의 값을 반환한다.
이와 같이, Generic Types을 사용하면 한 번의 함수 작성으로 다양한 타입의 인자를 받아들일 수 있으며, 코드의 재사용성과 유연성을 높일 수 있다.
Generic Types는 클래스, 인터페이스, 함수 등 다양한 TypeScript 요소에 적용될 수 있다.
제네릭 클래스(Generic Class)
제네릭 클래스(Generic Class)는 클래스 내부에서 사용되는 타입에 대해 제네릭을 적용할 수 있다. 제네릭 클래스를 정의할 때는 클래스 이름 뒤에 <T>와 같이 타입 인자를 추가한다.
class Box<T> {
private item: T;
constructor(item: T) {
this.item = item;
}
getItem(): T {
return this.item;
}
}
const stringBox = new Box("hello"); // string 타입의 Box 객체 생성
console.log(stringBox.getItem()); // "hello" 출력
const numberBox = new Box(42); // number 타입의 Box 객체 생성
console.log(numberBox.getItem()); // 42 출력
위 예시에서는 Box 클래스를 정의할 때 타입 인자 T를 사용했다. 이렇게 정의된 Box 클래스는 생성자에서 전달된 인자의 타입에 따라 getItem() 메소드에서 반환하는 값의 타입이 결정된다.
예를 들어, stringBox.getItem()은 string을 반환하며, numberBox.getItem()은 number를 반환한다.
제네릭 인터페이스(Generic Interface)
제네릭 인터페이스(Generic Interface)는 인터페이스 내부에서 사용되는 타입에 대해 제네릭을 적용할 수 있다. 제네릭 인터페이스를 정의할 때는 인터페이스 이름 뒤에 <T>와 같이 타입 인자를 추가한다.
interface Pair<T, U> {
first: T;
second: U;
}
const pair1: Pair<string, number> = { first: "hello", second: 42 };
console.log(pair1.first); // "hello" 출력
console.log(pair1.second); // 42 출력
const pair2: Pair<number, boolean> = { first: 123, second: true };
console.log(pair2.first); // 123 출력
console.log(pair2.second); // true 출력
위 예시에서는 Pair 인터페이스를 정의할 때 타입 인자 T와 U를 사용했다. 이렇게 정의된 Pair 인터페이스는 객체 타입을 정의할 때 타입 인자를 전달받아 사용한다.
예를 들어, Pair<string, number>는 first 속성이 string 타입이고 second 속성이 number 타입인 객체를 생성한다.
제네릭 함수(Generic Function)
제네릭 함수(Generic Function)는 함수 내부에서 사용되는 타입에 대해 제네릭을 적용할 수 있다. 제네릭 함수를 정의할 때는 함수 이름 뒤에 <T>와 같이 타입 인자를 추가한다.
function reverse<T>(items: T[]): T[] {
return items.reverse();
}
const numbers = [1, 2, 3, 4, 5];
const reversedNumbers = reverse(numbers); // [5, 4, 3, 2, 1]
const strings = ["apple", "banana", "orange"];
const reversedStrings = reverse(strings); // ["orange", "banana", "apple"]
위 예시에서는 reverse 함수를 정의할 때 타입 인자 T를 사용했다. 이 함수는 배열을 인자로 받아 역순으로 정렬된 새로운 배열을 반환한다.
numbers와 strings 변수를 각각 reverse 함수에 전달하면, 각각 [5, 4, 3, 2, 1]과 ["orange", "banana", "apple"]이 반환된다.
제네릭 제약조건(Generic Constraints)
Generic Types을 사용할 때, 타입 인자에 대한 제약을 둘 수 있다. 이를 제네릭 제약조건(Generic Constraints)이라고 한다. 제약조건을 사용하면 타입 인자가 특정 타입을 상속받거나 특정 인터페이스를 구현하는 타입으로 제한된다.
interface Lengthwise {
length: number;
}
function loggingIdentity<T extends Lengthwise>(arg: T): T {
console.log(`Length of ${arg} is ${arg.length}`);
return arg;
}
const foo = loggingIdentity("hello"); // Length of hello is 5
const bar = loggingIdentity([1, 2, 3]); // Length of 1,2,3 is 3
위 예시에서는 loggingIdentity 함수를 정의할 때, 타입 인자 T에 대해 extends Lengthwise 제약조건을 추가했다. 이 제약조건은 T 타입이 Lengthwise 인터페이스를 구현해야 한다는 것을 의미한다.
따라서 foo 변수에 "hello" 문자열을 전달하면 Length of hello is 5가 출력되며, bar 변수에 [1, 2, 3] 배열을 전달하면 Length of 1,2,3 is 3이 출력된다.
Generic Types는 TypeScript에서 함수, 클래스, 인터페이스 등의 요소에서 타입 안정성을 보장하기 위해 사용된다. 타입 인자를 사용해 코드를 재사용하고, 유연하게 작성할 수 있다.
Conditional Types(조건부 타입)
Conditional Types(조건부 타입)는 TypeScript의 고급 타입 기능 중 하나로, 조건에 따라 타입을 결정하는 기능을 제공한다. 즉, 타입을 검사하고 선택하는 방법을 지정하여 동적으로 타입을 생성하거나 선택할 수 있다.
interface User {
id: number;
name: string;
isAdmin: boolean;
}
여기서 isAdmin 프로퍼티가 true이면 해당 유저는 관리자이며, false이면 일반 사용자다.
이때, 함수의 매개변수로 User 타입을 받고, isAdmin이 true인 경우에는 UserWithRole 타입을 반환하고, false인 경우에는 UserWithoutRole 타입을 반환하도록 하려면 어떻게 해야 할까?
이런 경우에 Conditional Types을 사용할 수 있다.
type UserWithRole = {
id: number;
name: string;
isAdmin: true;
role: string;
};
type UserWithoutRole = {
id: number;
name: string;
isAdmin: false;
};
type UserRole<T> = T extends { isAdmin: true } ? UserWithRole : UserWithoutRole;
function getUser<T extends User>(user: T): UserRole<T> {
if (user.isAdmin) {
return {
...user,
role: 'admin',
} as UserRole<T>;
}
return user as UserRole<T>;
}
여기서 UserRole<T>는 Conditional Type으로, T 타입이 { isAdmin: true } 타입을 확장하는지 확인한다. 만약 그렇다면 UserWithRole 타입을 반환하고, 그렇지 않으면 UserWithoutRole 타입을 반환한다.
getUser 함수에서는 UserRole<T> 타입을 반환하도록 구현한다. 만약 user의 isAdmin이 true이면 UserWithRole 타입으로 변환하여 반환하고, 그렇지 않으면 UserWithoutRole 타입으로 변환하여 반환한다.
Intersection Types(교차 타입)
Intersection Types(교차 타입)는 두 개 이상의 타입을 합쳐 하나의 타입으로 만든다. 이를 통해 하나의 변수가 여러 타입의 속성을 모두 갖도록 할 수 있다.
Intersection Types은 &를 사용하여 타입들을 연결한다. 예를 들어, Person과 Serializable이라는 인터페이스를 가지고 있는 객체를 정의할 때는 Person & Serializable과 같이 작성할 수 있다.
interface Person {
name: string;
age: number;
}
interface Serializable {
serialize(): string;
}
type PersonSerializable = Person & Serializable;
const person: PersonSerializable = {
name: "Alice",
age: 30,
serialize() {
return JSON.stringify(this);
},
};
console.log(person.serialize()); // {"name":"Alice","age":30}
위 예시에서는 Person과 Serializable 두 개의 인터페이스를 정의하고, PersonSerializable 타입을 정의하여 Person과 Serializable의 속성을 모두 가지는 객체를 만든다. 그리고 person 변수는 이 PersonSerializable 타입의 객체를 가리킨다.
Intersection Types은 유니온 타입과 마찬가지로 타입의 조합을 통해 보다 복잡한 타입을 만들 때 유용하다.
Intersection Types은 다음과 같은 경우에 유용하게 사용될 수 있다.
두 개 이상의 객체를 합칠 때
예를 들어, Person과 Address라는 인터페이스가 있다고 가정해보자. Person에는 name과 age 속성이 있고, Address에는 city와 country 속성이 있다. 이 두 개의 객체를 하나로 합친 PersonWithAddress 객체를 만들고 싶다면 Intersection Types을 사용할 수 있다.
interface Person {
name: string;
age: number;
}
interface Address {
city: string;
country: string;
}
type PersonWithAddress = Person & Address;
const person: PersonWithAddress = {
name: "Alice",
age: 30,
city: "Seoul",
country: "Korea",
};
두 개 이상의 함수 시그니처를 합칠 때
Intersection Types은 함수 시그니처를 합칠 때도 사용될 수 있다. 예를 들어, Requestable과 Responsable이라는 인터페이스가 있다고 가정해보자. Requestable은 request 함수를 가지고 있고, Responsable은 response 함수를 가지고 있다. 이 두 개의 함수를 하나로 합친 RequestResponsable 함수를 만들고 싶다면 Intersection Types을 사용할 수 있다.
interface Requestable {
request(url: string, options: object): Promise<any>;
}
interface Responsable {
response(data: any): void;
}
type RequestResponsable = Requestable & Responsable;
function sendRequest(responseHandler: RequestResponsable, url: string, options: object) {
responseHandler
.request(url, options)
.then((data: any) => responseHandler.response(data))
.catch((error: Error) => console.error(error));
}
위 코드에서 sendRequest 함수는 RequestResponsable 타입의 객체와 url과 options 매개변수를 받아 request 함수를 호출하고, 그 결과를 response 함수로 전달한다. 이를 통해 Requestable과 Responsable 인터페이스를 동시에 만족하는 객체를 전달할 수 있다.
타입 가드를 사용할 때
타입 가드(Type Guard)란 TypeScript에서 특정 코드 블록 안에서 변수의 타입을 확실하게 인식할 수 있도록 도와주는 기능이다.
이를 통해 코드의 가독성과 안정성을 높일 수 있다.
Intersection 타입과 함께 타입 가드를 사용하는 경우가 많다. 예를 들어, 다음과 같은 상황에서 Intersection 타입과 타입 가드를 함께 사용할 수 있다.
interface User {
name: string;
age: number;
}
interface Admin {
name: string;
role: string;
}
function printUser(user: User & Admin) {
console.log(user.name);
if ('age' in user) {
console.log(user.age);
}
if ('role' in user) {
console.log(user.role);
}
}
위 코드에서 User와 Admin 인터페이스를 & 연산자로 조합한 Intersection 타입 User & Admin을 사용한다. 이렇게 하면 printUser 함수에서 User와 Admin 타입의 속성에 접근할 수 있다.
하지만 User와 Admin 인터페이스에 중복되는 name 속성이 존재한다. 그래서 User & Admin 타입에서 name 속성의 타입은 string으로 정의된다.
그러나 printUser 함수에서 user 매개변수의 age 속성에 접근하기 위해선 User 타입에 age 속성이 포함되어 있어야 한다. 따라서 if ('age' in user) 구문을 사용하여 age 속성이 존재하는지 여부를 확인하는 타입 가드를 사용한다.
마찬가지로 role 속성에 접근하기 위해서는 if ('role' in user) 구문을 사용하여 role 속성이 존재하는지 여부를 확인하는 타입 가드를 사용한다.
Union Types(유니온 타입)
Union Types(유니온 타입)은 여러 개의 타입 중 하나일 수 있는 변수를 정의할 때 사용한다. 이를 통해 다양한 데이터 타입을 적용할 수 있다.
Union Types은 파이프( | )를 사용하여 타입들을 연결한다. 예를 들어, string과 number타입의 Union Types을 만들 때는 string | number와 같이 작성할 수 있다.
function printId(id: number | string) {
console.log(`ID는 ${id}이다.`);
}
printId(101); // ID는 101이다.
printId("202"); // ID는 202이다.
위 예시에서는 printId() 함수의 인자로 number나 string 타입을 받을 수 있다. 따라서 printId(101)과 printId("202") 모두 유효한 호출이다.
조건부 타입(Conditional Type)과 함께 사용하기
Union Types은 조건부 타입(Conditional Type)과 함께 사용되어 보다 복잡한 타입을 정의할 때 유용하다.
Union Types은 선택적인 속성을 가지는 객체를 다룰 때 특히 유용하다.
interface Person {
name: string;
age?: number;
gender?: string;
}
위 코드에서 Person 인터페이스는 name 속성은 필수이고, age와 gender 속성은 선택적이다. 이를 구현한 객체는 다음과 같다.
const person1 = { name: "John" };
const person2 = { name: "Jane", age: 30 };
const person3 = { name: "James", gender: "Male" };
const person4 = { name: "Jasmine", age: 25, gender: "Female" };
위 객체들은 모두 Person 인터페이스를 만족하며, person1, person2는 age와 gender 속성이 없기 때문에 undefined 값을 가지게 된다.
Union Types을 사용하여 이를 더 간결하게 표현할 수 있다.
type PersonInfo = {
name: string;
} & (
| { age: number }
| { gender: string }
| { age: number; gender: string }
);
const person1: PersonInfo = { name: "John" };
const person2: PersonInfo = { name: "Jane", age: 30 };
const person3: PersonInfo = { name: "James", gender: "Male" };
const person4: PersonInfo = { name: "Jasmine", age: 25, gender: "Female" };
위 코드에서 PersonInfo 타입은 name 속성은 필수이며, age, gender 속성 중 하나 이상을 가질 수 있다. 이를 통해 객체를 더욱 명확하고 간결하게 정의할 수 있다.