JavaScript에서 클래스는 ES6(ECMAScript 2015)에서 처음 도입되었습니다. 클래스를 사용하면 객체를 생성하고 객체의 속성과 메서드를 정의할 수 있습니다. 예를 들어, 다음과 같은 JavaScript 클래스를 만들 수 있습니다.
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
greet() {
console.log(`안녕하세요, 제 이름은 ${this.name}이고, ${this.age}살 입니다.`);
}
}
위 코드에서 Person 클래스는 name과 age 속성을 가지고 greet() 메서드를 정의합니다. 이 클래스를 사용하여 객체를 생성하려면 다음과 같이 할 수 있습니다.
const person = new Person('Alice', 30);
person.greet(); // "안녕하세요, 제 이름은 Alice이고, 30살 입니다."
TypeScript의 클래스는 JavaScript의 클래스와 비슷하지만 몇 가지 추가된 기능이 있습니다. 예를 들어, TypeScript에서는 클래스의 속성과 메서드에 대한 타입을 명시할 수 있습니다. 다음은 TypeScript의 클래스 예시입니다.
class Person {
name: string;
age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
greet(): void {
console.log(`안녕하세요, 제 이름은 ${this.name}이고, ${this.age}살 입니다.`);
}
}
위 코드에서 name 속성과 age 속성은 문자열과 숫자 타입으로 정의되어 있습니다. 약간의 차이점은 TypeScript에서 클래스를 정의할 때, constructor를 이용하여 초기화하는 멤버들은 전부 상단에서 정의를 해줘야 한다는 것입니다. 또한 contructor 내 인자로 받을 때도 정확히 타입을 명시해 줘야 합니다.
const person = new Person('Alice', 30);
person.greet(); // "안녕하세요, 제 이름은 Alice이고, 30살 입니다."
TypeScript를 사용하여 객체를 생성할 때도 위와 같이 JavaScript와 비슷한 방식으로 할 수 있습니다.
TypeScript의 클래스(class)는 인터페이스(interface)와 마찬가지로 기존에 존재하던 클래스를 상속받아 확장하여 새로운 클래스를 만들 수 있습니다. 이때도 extends 키워드를 사용하여 상속할 수 있습니다.
class Animal {
move(distanceInMeters: number): void {
console.log(`${distanceInMeters}m 이동했습니다.`);
}
}
class Dog extends Animal {
speak(): void {
console.log("멍멍!");
}
}
const dog = new Dog();
dog.move(10);
dog.speak();
위의 코드에서 Animal이라는 클래스를 Dog라는 클래스가 상속받고 있습니다. Dog 클래스는 Animal 클래스로부터 프로퍼티와 메서드를 상속받으며, Dog 클래스는 파생 클래스라고도 불리며, 하위클래스(subclasses)라고도 불립니다. 여기서 Animal 클래스는 기초 클래스, 상위클래스(superclasses)라고 불립니다.
기본적으로 클래스 내에 선언된 멤버는 외부로 공개되는 것이 디폴트 값입니다. 그러나 공개된다고 명시적으로도 표시해 줄 수 있습니다. 이때 public 키워드를 사용하면 됩니다.
class Person {
public name: string;
public age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
greet(): void {
console.log(`안녕하세요, 제 이름은 ${this.name}이고, ${this.age}살 입니다.`);
}
}
혹은 외부에 드러내지 않을 멤버가 있다면 private 키워드로 명시해 주면 됩니다.
class Person {
public name: string;
private age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
greet(): void {
console.log(`안녕하세요, 제 이름은 ${this.name}이고, ${this.age}살 입니다.`);
}
}
readonly 키워드를 사용하여 프로퍼티를 읽기 전용으로 만들 수 있습니다. 읽기 전용 프로퍼티들은 선언 또는 생성자에서 초기화해야 합니다.
class Mydog {
readonly name: string;
constructor(theName: string) {
this.name = theName;
}
}
let spooky = new Mydog("스푸키");
spooky.name = "멋진 스푸키"; // 에러
위의 코드는 name이 readonly로 명시되어 있기 때문에, 값을 변경할 수 없습니다. 이런 식으로 변경되면 안 될 값을 readonly로 명시하여 보호할 수 있습니다.
class Counter {
private value: number;
constructor() {
this.value = 0;
}
public increase(): void {
this.value++;
}
public decrease(): void {
this.value--;
}
public getValue(): number {
return this.value;
}
}
let counter1 = new Counter();
counter1.increase();
console.log(counter1.getValue());
class Animal {
private name: string;
constructor(theName: string) {
this.name = theName;
}
public speak(sound : string = '왕왕!'): void {
console.log(`${this.name}(은/는) ${sound}하고 웁니다.`);
}
}
class Mouse extends Animal {
constructor(name: string) {
super(name);
}
public speak(sound: string = '찍찍'): void {
super.speak(sound);
}
}
class Cat extends Animal {
constructor(name: string) {
super(name);
}
public speak(sound : string = '야옹'): void {
super.speak(sound);
}
}
let jerry = new Mouse('제리');
let tom = new Cat('톰');
jerry.speak();
tom.speak('냥냥');
타입스크립트의 제네릭(Generic)은 코드 재사용성을 높이고 타입 안정성을 보장하는 기능입니다. 제네릭을 사용하면 함수나 클래스를 작성할 때, 사용될 데이터의 타입을 미리 지정하지 않고, 이후에 함수나 클래스를 호출할 때 인자로 전달된 데이터의 타입에 따라 자동으로 타입을 추론하게 됩니다. 아래와 같은 코드가 있습니다.
function printLog(text) {
return text;
}
위의 printLog 함수는 파라미터로 text를 받고 있으며, 반환 값으로 text를 리턴하고 있습니다. 이를 제네릭 없이 구현한다면 아래와 같이 구현할 수 있을 것입니다.
function printLog(text: string): string {
return text;
}
printLog('hello'); // 정상
printLog(123); //에러
첫 번째 함수는 printLog 함수에 특정 타입을 주어 작성한 코드입니다. 타입은 명시되었지만, string 타입 외에 다른 타입이 들어온다면 컴파일 에러가 날 것입니다.
이를 해결하기 위해서 중복으로 함수를 선언하는 방법이 있을 것입니다.
function printLog(text: string): string {
return text;
}
function printLogNumber(text: number): number {
return text;
}
printLog('hello'); // 정상
printLogNumber(123); //정상
그러나 이 방법은 타입의 가독성 및 유지보수성이 나빠집니다. 타입을 다르게 받기 위해 같은 코드를 타입만 바꿔서 명시하는 것이기 때문입니다.
또는 | 연산자를 이용해 유니온 타입으로 선언하는 방법이 있을 것입니다.
function printLog(text: string | number) {
return text;
}
printLog('hello'); // 정상
printLogNumber(123); //정상
이 방법은 들어가는 인수는 해결이 되지만, 함수 내에서 결국 string과 number가 둘 다 접근할 수 있는 API만 제공합니다. 이 외에는 타입이 정확히 추론되지 않기 때문에 사용할 수 없습니다.
이어 any 타입을 사용해 작성한 코드입니다.
function printLog(text: any): any {
return text;
}
이 방법은 어떤 타입이든 받을 수 있지만 실제로 함수가 반환할 때 어떤 타입인지 추론할 수 없게 됩니다.
따라서 제네릭을 사용하게 될 필요성이 생깁니다.
제네릭을 사용하면 이와 같이 작성할 수 있습니다.
function printLog<T>(text: T): T {
return text;
}
printLog 함수에 T라는 타입 변수를 추가했습니다. T는 유저가 준 파라미터의 타입을 캡처하고, 이 정보를 나중에 사용할 수 있게 합니다. 여기에서는 T를 반환 타입으로 다시 사용합니다. 따라서 파라미터와 반환 타입이 같은 타입을 사용하고 있는 것을 확인할 수 있습니다.
printLog 함수는 타입을 불문하고 동작하므로 제네릭이라 할 수 있습니다. any를 쓰는 것과는 다르게 인수와 반환 타입에 string을 사용한 첫 번째 printLog 함수만큼 정확합니다. 즉 타입을 추론할 수 있게 됩니다.
이렇게 제네릭을 작성하고 나면 아래와 같이 작성할 수 있습니다.
const str = printLog<string>('hello');
여기서 함수를 호출할 때의 인수 중 하나로써 T를 string 타입으로 명시해 주고 타입 주변을 <>로 감싸주었습니다.
혹은 타입 추론 기능을 활용해서 작성할 수 있습니다.
const str = printLog('hello');
이는 타입 추론 기능을 활용해 작성한 코드입니다. 전달하는 인수에 따라 컴파일러가 자동으로 T의 값을 정하는 방법입니다. 이는 타입이 복잡해져 컴파일러가 타입을 유추할 수 없게 되는 경우에는 사용할 수 없는 방법입니다.
인터페이스에도 제네릭을 사용할 수 있습니다.
interface Item<T> {
name: T;
stock: number;
selected: boolean;
}
이와 같이 작성하면 Item 인터페이스를 사용하여 만든 객체는 name의 값으로 어떤 타입이 들어갈지만 작성을 해주면 인터페이스를 여러 개 만들지 않고도 재사용을 할 수 있게 됩니다.
const obj: Item<string> = {
name: "T-shirts",
stock: 2,
selected: false
};
const obj: Item<number> = {
name: 2044512,
stock: 2,
selected: false
};
이런 식으로 여러 개의 객체를 만들어 낼 수 있게 됩니다.
제네릭을 사용하는 TypeScript에서 팩토리를 생성할 때 생성자 함수로 클래스 타입을 참조해야 합니다.
class GenericNumber<T> {
zeroValue: T;
add: (x: T, y: T) => T;
}
let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function(x, y) { return x + y; };
이것은 GenericNumber 클래스의 문자 그대로 사용하지만 number 타입만 쓰도록 제한하는 것은 없습니다. 대신 string이나 훨씬 복잡한 객체를 사용할 수 있습니다.
제네릭을 사용하기 시작하면, printLog와 같은 제네릭 함수를 만들 때, 컴파일러가 함수 본문에 제네릭 타입화된 매개변수를 쓰도록 강요합니다.
앞서 본 printLog 함수를 예시로 보겠습니다.
function printLog<T>(text: T): T {
console.log(text.length);
return text;
}
위와 같이 console.log(text.length);를 작성하게 되면 컴파일 에러가 납니다. 왜냐하면 개발자가 string 타입이 아닌 number 타입을 보낼 수도 있기 때문에, T에는 .length가 있다는 것을 추론할 수 없기 때문입니다.
이때는 제네릭에 타입을 줘서 유연하게 함수의 타입을 정의해 줄 수 있습니다.
function printLog<T>(text: T[]): T[] {
console.log(text.length);
return text;
}
이 제네릭 함수 코드는 일단 T라는 변수 타입을 받고, 인자 값으로는 배열 형태의 T를 받습니다. 따라서 제네릭 타입이 배열이기 때문에, .length를 허용하게 됩니다.
혹은 다음과 같이 조금 더 명시적으로 작성이 가능합니다.
function printLog<T>(text: Array<T>): Array<T> {
console.log(text.length);
return text;
}
앞서 제네릭 타입 변수 외에도 제네릭 함수에 어느 정도 어떤 타입이 들어올 것인지 힌트를 줄 수 있습니다.
다시 앞서 본 printLog 함수를 예시로 보겠습니다.
function printLog<T>(text: T): T {
console.log(text.length);
return text;
}
인자의 타입에 선언한 T는 아직 어떤 타입인지 구체적으로 정의하지 않았기 때문에 length 코드에서 오류가 납니다. 이럴 때 만약 해당 타입을 정의하지 않고도 length 속성 정도는 허용하려면 아래와 같이 작성합니다.
interface TextLength {
length: number;
}
function printLog<T extends TextLength>(text: T): T {
console.log(text.length);
return text;
}
이와 같이 extends 지시자를 이용해 작성하게 되면 타입에 대한 강제는 아니지만 length에 대해 동작하는 인자만 넘겨받을 수 있게 됩니다.
혹은 keyof를 이용해서 제약을 줄 수도 있습니다.
interface Item<T> {
name: T;
stock: number;
selected: boolean;
}
function printLog<T extends keyof Item>(text: T): T {
return text;
}
printLog('name'); //정상
pirntLog('key'); //에러
제네릭을 선언할 때 T extends keyof Item 부분에서 첫 번째 인자로 받는 객체에 없는 속성들은 접근할 수 없게끔 제한할 수 있습니다.
function getFirstNumber<T>(numbers: T[]): T | undefined {
if (numbers.length === 0) {
return undefined;
}
return numbers[0];
}
function getFirstString<T>(strings: T[]): T | undefined {
if (strings.length === 0) {
return undefined;
}
return strings[0];
}
const numbers: number[] = [1, 2, 3];
const firstNumber = getFirstNumber(numbers); // firstNumber의 타입은 number | undefined입니다.
console.log(firstNumber);
const strings: string[] = ['apple', 'banana', 'cherry'];
const firstString = getFirstString(strings); // firstString의 타입은 string | undefined입니다.
console.log(firstString);