TypeScript - 2
저번 TypeScript에서는 간단한 소개와 Typescript의 자료형들, 그리고 변수선언에 대해서 알아봤다. 이번에는 함수에서 인자로 받을 수 있는 자료형을 제한하는 방법들과 인터페이스 및 클래스 선언에 대해서 알아볼 것이다.
Type Inference
TypeScript는 타입 추론 기능을 제공한다. 그래서 변수나 함수 선언시에 타입을 지정하는 것은 필수가 아니다.
let value1: string = 'foo'; // string type
let value2 = 'bar'; // string type
Function Parameter Type
기존에 JavaScript에서 객체를 생성할때 아래와 같은 방법을 많이 사용했을 것이다. 그리고 생성한 객체를 함수에서 사용할 수 있도록 인자를 전달하곤 할 것이다.
var content = {
id: 10,
title: 'This is title',
text: 'Hello!!'
};
function printContent(content) {
console.log(content.title.toUpperCase());
console.log(content.text);
}
printContent(content);
JavaScript에서는 함수 인자의 타입을 검사하기 않게 때문에 은근히 불편한 점을 제공한다. 예를 들면, 아래의 코드를 보자.
var noContent = {
id: 11,
noContent: 'hello'
}
printContent(noContent);
위 코드를 실행하면 당연히 에러가 발생할 것이다. noContent 객체에는 title와 content라는 필드가 존재하지 않기 때문이다. 어쨋든 에러가 나야 정상인 상황에서 에러가 났으니 당연하고 별일 아니라고 생각할 수도 있는데, 문제는 이 에러가 실행 중에 발생한다는 것이다. 에러는 빨리 발견하면 할수록 좋다. printContent 함수를 사용하는데 전달되는 인자가 에초에 printContent 함수가 생각해두지 않은 형태의 객체일 수도 있고, 개발자가 개발 중에 실수로 필드 키에 오타를 낸 것일 수도 있다. 그러나 JavaScript라는 언어의 특성상 함수 내부에서 title과 content 필드를 사용하고 있다고한들, 전혀 문제가 없는 코드이다. 위 코드는 작성 중엔 아무 문제 없다가 나중에 누군가가 저 코드를 실행하는 순간에서야 에러를 발생시킬 것이다.
이러한 문제를 해결하는 것이 TypeScript의 가장 큰 목표 중 하나이다. TypeScript에서는 타입을 지정하여 위와 같은 에러를 사전에 예방한다. TypeScript로 타입을 지정한다면 아래와 같은 형태로 하수를 다시 작성할 수 있을 것이다.
function printContent(content: { title: string, text: string }) {
console.log(content.title.toUpperCase());
console.log(content.text);
}
위 함수처럼 파라미터로 받을 객체의 형태를 지정할 수 있다. 파라미터로 전달되는 content는 string 타입의 title 필드와 string 타입의 text 필드를 가지고 있어야한다. 이제 알맞지 않은 파라미터가 전달됨으로써 발생하는 에러는 실행 전에 미리 발견할 수 있는 것이다. 만일 text라는 필드가 필수가 아니라면 필드명에 ?를 붙이면 된다.
function printContent(content: { title: string, text?: string }) {
console.log(content.title.toUpperCase());
console.log(content.text);
}
필드명 뒤에 ?를 붙이면 그 필드는 필수가 아니라는 의미이다. 그리고 TypeScript는 함수의 파라미터는 보다 편하게 쓸 수 있도록 편의문법도 제공한다.
function printContent({ title, text }: { title: string, text: string }) {
console.log(title.toUpperCase());
console.log(text);
}
파라미터로 전달되는 객체의 각 필드들을 함수의 파라미터로 바인딩할 수 있다. 그런데 위 코드와 같이 쓰면 파라미터를 작성하는데 손이 많이 가는 것 같다. 파라미터가 2개 밖에 안되는데 코드가 참 길어졌다. 만일 동일한 조건을 가진 함수가 많아지면 중복된 코드가 들어가게 된다. 이제 인터페이스와 클래스를 사용할때이다.
인터페이스
인터페이스를 선언하는 방법은 간단하다.
인터페이스 선언
interface Content {
title: string;
text?: string;
}
let content: Content = { title: "hello", text: "world" };
TypeScript 인터페이스의 특이한 점이라면 멤버필드로 인터페이스로 선언할 수 있다는 것이다. 위 Animal 인터페이스는 getSpecies 메소드를 포함하여 name이라는 필드도 존재해야한다는 의미이다. 또한, 필드명에 ?를 추가하면 해당 필드는 optional하다는 의미로 구현이 필수가 아니다. 인터페이스를 사용해서 printContent 함수를 좀 더 깔끔하게 작성할 수 있다.
function printContent(content: Content) {
console.log(content.title.toUpperCase());
console.log(content.text);
}
printContent({ title: "hello", text: "This is content1" }); // ok
printContent({ title: "bye" }); // ok
부가적인 속성
그런데 인터페이스에 선언한 필드나 메소드 이외에 부가적인 속성들을 함께 전달하고 싶다고 한다면 지금으로선 약간의 문제가 있다. 아래의 함수 호출문을 보자.
printContent({ title: "hello", text: "This is content1", titleColor: "red"}); // error
위 함수 호출에 에러가 발생한다. Content 인터페이스에서 선언한 title, text 필드 이외에 titleColor라는 필드가 추가됬기 때문이다. 이 경우에는 가지고 있는 필드가 Content 인터페이스와는 다르기 때문에 그냥 전달이 안된다. 이미 title과 text 필드를 가지고 있기 때문에 이 객체는 Content를 이미 구현하고 있다. 그렇다면 형변환을 해줄 수 있다.
printContent({ title: "hello", text: "This is content1", titleColor: "red"} as Content); // ok
위 함수 호출은 더 이상 에러를 발생시키지 않는다. 그러나 함수 내부적으로 titleColor를 content.titleColor
처럼 사용할 수는 없을 것이다. Content는 titleColor라는 필드를 가지고 있지 않기 때문이다(그러나 에러를 무시하고 강제로 실행하면 문제없이 titleColor의 값을 이용할 수 있다. 어쨋든 JavaScript에서는 가능한 문법이기 때문이다).
그렇다면 다른 방법을 사용할 수 있다. 필드값을 content["titleColor"]
와 같은 방법으로 얻으면 된다. 이는 TypeScript에서도 JavaScript에서도 문제없는 문법이다. 단, TypeScript에서는 앞에서와 같은 문법으로 사용하더라도 타입을 검증할 수 있는 방법을 제공한다.
interface Content {
title: string;
text?: string;
[key: string]: any;
}
printContent({ title: "hello", text: "world", titleColor: "red" }); // content["titleColor"]로 접근 가능
Content 인터페이스에 추가된 [key: string]: any
의 의미는, printContent 함수의 파라미터명이 content라고 한다면 content[key]
와 같은 방법으로 부가적인 속성값을 얻을 수 있다는 의미이다. 이때 key는 string 타입이며 반환되는 값은 any 타입이다.
함수 인터페이스
인터페이스는 클래스 및 객체에 대해서만 적용 가능한 것이 아니라 함수에도 적용할 수 있다. 즉, 함수의 형태를 인터페이스로 선언할 수 있다.
interface Predicate {
(x: any): boolean;
}
function filter<T>(dataset: Array<T>, predicate: Predicate) {
return dataset.filter(predicate);
}
console.log(filter([1, 2, 3, 4, 5], (x: number) => x > 3));
// Output
// 4, 5
상속
extends 키워드를 사용하여 인터페이스를 상속할 수 있다.
interface Content {
...
}
interface Book extends Content {
...
}
구현
클래스에서 인터페이스를 구현할 때에는 implements 키워드를 사용한다.
class Book implements Content {
...
}
클래스
클래스는 기존의 다른 OOP 언어에서 볼 수 있는 모습과 매우 유사한 모습을 하고 있다. 역시 필드와 메소드로 구성되어있으며 상속과 다형성을 지원한다.
// 클래스 정의
class Person {
firstName: string;
lastName: string;
constructor(firstName: string, lastName: string) {
this.firstName = firstName;
this.lastName = lastName;
}
getFullName(): string {
return `${this.firstName} ${this.lastName}`;
}
}
// 클래스 생성
let kwSeo = new Person("kw", "Seo");
필드
class Person {
firstName: string;
lastName: string;
...
}
필드를 선언하는 방법은 매우 간단하다. 필드명과 타입을 명시해주면 된다.
메소드
class Person {
...
getFullName(): string {
return `${this.firstName} ${this.lastName}`;
}
}
메소드를 선언하는 방법도 간단하다. 함수를 선언하듯이 선언하면 되지만, function 키워드를 사용하면 안된다. 엄연히 함수와 메소드는 다른 것이기 때문이다.
상속
class Student extends Person {
grade: number;
constructor(firstName: string, lastName: string, grade: number) {
super(firstName, lastName);
this.grade = grade;
}
}
상속 방법도 Java와 매우 유사하다. extends 키워드를 사용해서 상속받을 클래스를 지정하면 되며, 부모 생성자를 호출할때에는 super를 호출하면 된다.
접근제어자
TypeScript에도 다른 언어처럼 각 필드 및 메소드에 대한 접근을 제한할 수 있다. 접근 제어자에는 4가지 종류가 있으며 아무것도 지정하지 않는 다면 기본으로 public으로 설정된다.
- private: 클래스 내부에서만 접근 가능
- protected: 클래스 내부와 상속한 클래스 내부에서만 접근 가능
- public: 모든 곳에서 접근 가능
- readonly: 이름 그대로 읽기 전용으로 설정한다. readonly로 설정된 필드는 반드시 선언 즉시 초기화되거나 생성자 내부에서 초기화되어야 한다(TypeScript에서 클래스는 const 멤버를 가질 수 없다).
class Person {
private firstName: string;
private lastName: string;
constructor(firstName: string, lastName: string) {
this.firstName = firstName;
this.lastName = lastName;
}
public getFullName(): string {
return `${this.firstName} ${this.lastName}`;
}
}
Parameter Properties
...
constructor(firstName: string, lastName: string) {
this.firstName = firstName;
this.lastName = lastName;
}
...
위 코드처럼 생성자에서 받은 값들을 멤버필드에 할당해주는 작업은 매우 흔한 일이다. TypeScript에서는 이를 보다 편리하게 할 수 있는 방법을 제공한다.
class Person {
constructor(private firstName: string, private lastName: string) { }
public getFullName(): string {
return `${this.firstName} ${this.lastName}`;
}
}
생성자의 파라미터에 접근제어자를 추가하면 자동으로 멤버필드로 할당해준다. 별도로 멤버필드를 선언하는 작업과 값을 할당하는 작업을 생략할 수 있다.
Static Properties
class Adder {
static values = { a: 0, b: 10 };
constructor() {
Adder.values.a++;
}
}
let adder1 = new Adder();
console.log(Adder.values.a);
let adder2 = new Adder();
console.log(Adder.values.a);
// Output
// 1
// 2
정적 변수는 클래스를 정의할때 생서되는 값으로 모든 클래스 인스턴스가 공유한다. 새로운 인스턴스가 생성되도 정적 변수는 새로 생성되는 것이 아닌 기존의 참조값을 공유한다. 또한, 정적 변수는 인스턴스가 아닌 클래스를 통해 접근할 수 있다.
추상 클래스
abstract class Something {
abstract toString(): string;
}
추상 클래스는 인스턴스를 생성할 수 없으며, abstract로 선언된 메소드는 하위 클래스에서 반드시 구현되어야 한다.
Accessors
객체의 필드값을 얻거나 수정하기위해서 getter와 setter 메소드를 정의하고는 하는데, TypeScript는 getter와 setter를 정의하는 별도의 방법이 존재한다.
class Person {
constructor(private _firstName: string, private _lastName: string) {
}
get firstName() {
console.log("getter of firstName");
return this._firstName;
}
set firstName(newFirstName: string) {
console.log("setter of firstName");
this._firstName = newFirstName;
}
}
let kwSeo = new Person("kw", "Seo");
kwSeo.firstName = "newFirstName";
console.log(kwSeo.firstName);
// Output
// setter of firstName
// getter of firstName
// newFirstName
메소드 앞에 get 또는 set 키워드를 붙이면 그 메소드는 getter 또는 setter가 된다. 필드명과 중복될 수는 없다. 이렇게 getter와 setter를 메소드처럼 정의를 했지만, 실제로 사용할 때에는 기존에 멤버필드를 다루듯이 사용하면 된다. 위 코드에서 처럼 =
연산자를 사용하면 setter가 호출되며, 아무것도 없이 사용하면 getter가 호출된다.