자바스크립트는 1995년 넷스케이프사의 브렌던 아이크(Brendan Eich)가 자사의 웹브라우저인 Navigator 2에 탑재하기 위해 개발한 스크립트 언어이다. 초창기 자바스크립트는 웹페이지의 보조적인 기능을 수행하기 위해 한정적인 용도로 사용되었다. 이 시기에 대부분 로직은 주로 웹서버에서 실행되었고 브라우저(클라이언트)는 서버로부터 전달받은 HTML과 CSS를 렌더링하는 수준이었다.
[HTML5]가 등장하기 이전까지 웹 애플리케이션은 플래시, 실버라이트, 액티브엑스와 같은 플러그인에 의존하여 인터랙티브한 웹페이지를 구축해왔다. 그러다가 HTML5가 등장함으로써 플러그인에 의존하던 구축 방식은 자바스크립트로 대체되었다. 또한 AJAX의 활성화로 데스크탑 애플리케이션과 유사한 사용자 경험을 제공할 수 있는 SPA(Single Page Application)가 대세가 되었다. 이로써 과거 서버 측이 담당하던 업무의 많은 부분이 클라이언트 측으로 이동하게 되었고, 자바스크립트는 웹의 어셈블리 언어로 불릴 만큼 중요한 언어로 그 위상이 높아지게 되었다.
모든 프로그래밍 언어에 장단점이 있듯이 자바스크립트도 언어가 잘 정제되기 이전에 서둘러 출시된 문제와 과거 웹페이지의 보조적인 기능을 수행하기 위해 한정적인 용도로 만들어진 태생적 한계로 좋은 점도, 나쁜 점도 많은 것이 사실이다.
자바스크립트는 C나 Java와 같은 C-family 언어와는 구별되는 아래와 같은 특성이 있다.
이와 같은 특성은 클래스 기반 객체지향 언어(Java, C++, C# 등)에 익숙한 개발자를 혼란스럽게 하며 코드가 복잡해질 수 있고 디버그와 테스트 공수가 증가하는 등의 문제를 일으킬 수 있어 특히 규모가 큰 프로젝트에서는 주의하여야 한다.
이같은 자바스크립트의 태생적 문제를 극복하고자 CoffeeScript, Dart, Haxe와 같은 AltJS(자바스크립트의 대체 언어)가 등장하였다.
TypeScript 또한 자바스크립트 대체 언어의 하나로써 자바스크립트(ES5)의 Superset(상위확장)이다. C#의 창시자인 덴마크 출신 소프트웨어 엔지니어 Anders Hejlsberg(아네르스 하일스베르)가 개발을 주도한 TypeScript는 Microsoft에서 2012년 발표한 오픈소스로, 정적 타이핑을 지원하며 ES6(ECMAScript 2015)의 클래스, 모듈 등과 ES7의 Decorator 등을 지원한다.
TypeScript는 ES5의 Superset이므로 기존의 자바스크립트(ES5) 문법을 그대로 사용할 수 있다. 또한, ES6의 새로운 기능들을 사용하기 위해 Babel과 같은 별도 트랜스파일러(Transpiler)를 사용하지 않아도 ES6의 새로운 기능을 기존의 자바스크립트 엔진(현재의 브라우저 또는 Node.js)에서 실행할 수 있다.
이후 ECMAScript의 업그레이드에 따른 새로운 기능을 지속적으로 추가할 예정이여서 매년 업그레이드될 ECMAScript의 표준을 따라갈 수 있는 좋은 수단이 될 것이다.
더욱이 AngularJS의 후속 버전인 Angular의 TypeScript 정식 채용으로 TypeScript에 관심이 커져가고 있다.
구글은 당초 Angular의 주력 언어로 TypeScript의 상위 집합인 AtScript를 계획했지만, TypeScript를 채용하는 것으로 방향을 전환했다. 그 배경으로 Angular에 필요한 기능을 TypeScript의 사양에 포함하는 것을 TypeScript 진영과 합의한 것으로 전해진다.
또한 구글은 2년간의 검토 끝에 2017년 3월 사내 표준 언어(Canonical Languages)로 TypeScript의 사용을 승인하였다. 구글은 구글애널리틱스, 파이어베이스, 구글 클라우드 플랫폼 등 대규모 프로젝트와 버그 추적, 채용 검토, 제품 승인 및 출시 도구와 같은 핵심적인 내부 도구에 TypeScript와 TypeScript 기반 Angular를 사용하고 있다.
TypeScript를 사용하는 가장 큰 이유 중 하나는 정적 타입을 지원한다는 것이다.
function sum(a, b) {
return a + b;
}
위 함수를 정의한 개발자의 의도는 아마도 2개의 숫자 타입 인수를 전달받아 그 합계를 반환하려는 것으로 추측된다. 하지만 코드상으로는 어떤 타입의 인수를 전달하여야 하는지, 어떤 타입의 반환값을 리턴해야 하는지 명확하지 않다. 따라서 위 함수는 아래와 같이 호출될 수 있다.
function sum(a, b) {
return a + b;
}
sum('x', 'y'); // 'xy'
위 코드는 자바스크립트 문법상 어떠한 문제도 없으므로 자바스크립트 엔진은 아무런 이의 제기없이 위 코드를 실행할 것이다. 이러한 상황이 발생한 이유는 변수나 반환값의 타입을 사전에 지정하지 않는 자바스크립트의 동적 타이핑(Dynamic Typing)에 의한 것이다.
function sum(a: number, b: number) {
return a + b;
}
sum('x', 'y');
// error TS2345: Argument of type '"x"' is not assignable to parameter of type 'number'.
TypeScript는 정적 타입을 지원하므로 컴파일 단계에서 오류를 포착할 수 있는 장점이 있다. 명시적인 정적 타입 지정은 개발자의 의도를 명확하게 코드로 기술할 수 있다. 이는 코드의 가독성을 높이고 예측할 수 있게 하며 디버깅을 쉽게 한다.
TypeScript를 사용하는 이유는 여러가지 있지만 가장 큰 장점은 IDE(통합개발환경)를 포함한 다양한 도구의 지원을 받을 수 있다는 것이다. IDE와 같은 도구에 타입 정보를 제공함으로써 높은 수준의 인텔리센스(IntelliSense), 코드 어시스트, 타입 체크, 리팩토링 등을 지원받을 수 있으며 이러한 도구의 지원은 대규모 프로젝트를 위한 필수 요소이기도 하다.
인터페이스, 제네릭 등과 같은 강력한 객체지향 프로그래밍 지원은 크고 복잡한 프로젝트의 코드 기반을 쉽게 구성할 수 있도록 도우며, Java, C# 등의 클래스 기반 객체지향 언어에 익숙한 개발자가 자바스크립트 프로젝트를 수행하는 데 진입 장벽을 낮추는 효과도 있다.
브라우저만 있으면 컴파일러 등의 개발환경 구축없이 바로 사용할 수 있는 ES5와 비교할 때, 개발환경 구축 관점에서 다소 복잡해진 측면이 있지만 현재 ES6를 완전히 지원하지 않고 있는 브라우저를 고려하여 Babel 등의 트랜스파일러를 사용해야 하는 현 상황에서 TypeScript 개발환경 구축에 드는 수고는 그다지 아깝지 않을 것이다. 또한, TypeScript는 아직 ECMAScript 표준에 포함되지는 않았지만 표준화가 유력한 스펙을 선제적으로 도입하므로 새로운 스펙의 유용한 기능을 안전하게 도입하기에 유리하다.
마지막으로 Angular는 TypeScript 뿐만 아니라 자바스크립트(ES5, ES6), Dart로도 작성할 수 있지만 Angular 문서, 커뮤니티 활동에서 가장 많이 사용되고 있는 것이 TypeScript이다. Angular 관련 문서의 예제 등도 TypeScript로 작성된 것이 대부분이어서 관련 정보를 얻을 때 이점이 있으며 이러한 현상은 앞으로도 지속될 것으로 예상된다.
TypeScript 파일(.ts)은 브라우저에서 동작하지 않으므로 TypeScript 컴파일러를 이용해 자바스크립트 파일로 변환해야 한다. 이를 컴파일 또는 트랜스파일링이라 한다.
TypeScript 컴파일러를 설치하여 TypeScript 개발환경을 구축하고 TypeScript 컴파일러의 사용 방법에 대해 살펴보도록 하자.
Node.js를 설치하면 [npm]도 같이 설치된다. 다음과 같이 터미널(윈도우의 경우 커맨드창)에서 npm을 사용하여 TypeScript를 전역에 설치한다.
$ npm install -g typescript
설치가 완료되었으면 버전을 출력하여 TypeScript의 설치를 확인한다.
$ tsc -v
Version 4.1.5
TypeScript 컴파일러(tsc)는 TypeScript 파일(.ts)을 자바스크립트 파일로 트랜스파일링한다.
컴파일은 일반적으로 소스 코드를 바이트 코드로 변환하는 작업을 의미한다. TypeScript 컴파일러는 TypeScript 파일을 자바스크립트 파일로 변환하므로 컴파일보다는 트랜스파일링(Transpiling)이 보다 적절한 표현이다.
// person.ts
class Person {
private name: string;
constructor(name: string) {
this.name = name;
}
sayHello() {
return 'Hello, ' + this.name;
}
}
const person = new Person('Lee');
console.log(person.sayHello());
트랜스파일링을 실행해 본다. tsc 명령어 뒤에 트랜스파일링 대상 파일명을 지정한다. 이때 확장자 .ts는 생략할 수 있다.
$ tsc person
트랜스파일링 실행 결과, 같은 디렉터리에 자바스크립트 파일(person.js)이 생성된다.
// person.js
var Person = /** @class */ (function () {
function Person(name) {
this.name = name;
}
Person.prototype.sayHello = function () {
return 'Hello, ' + this.name;
};
return Person;
})();
var person = new Person('Lee');
console.log(person.sayHello());
이때 트랜스파일링된 person.js의 자바스크립트 버전은 ES3이다. 이는 TypeScript 컴파일 타겟 자바스크립트 기본 버전이 ES3이기 때문이다.
만약, 자바스크립트 버전을 변경하려면 컴파일 옵션에 --target
또는 -t
를 사용한다. 현재 tsc가 지원하는 자바스크립트 버전은 ‘ES3’(default), ‘ES5’, ‘ES2015’, ‘ES2016’, ‘ES2017’, ‘ES2018’, ‘ES2019’, ‘ESNEXT’이다. 예를 들어, ES6 버전으로 트랜스파일링을 실행하려면 아래와 같이 옵션을 추가한다.
$ tsc person -t ES2015
// person.js
class Person {
constructor(name) {
this.name = name;
}
sayHello() {
return 'Hello, ' + this.name;
}
}
const person = new Person('Lee');
console.log(person.sayHello());
트랜스파일링이 성공하여 자바스크립트 파일이 생성되었으면, Node.js REPL을 이용해 트랜스파일링된 person.js를 실행해본다.
$ node person
Hello, Lee
매번 옵션을 지정하는 것은 번거로우므로 tsc 옵션 설정 파일을 생성하도록 하자.
$ tsc --init
message TS6071: Successfully created a tsconfig.json file.
아래와 같이 tsc 옵션 설정 파일인 tsconfig.json이 생성된다.
{
"compilerOptions": {
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
// "lib": [],
tsc 명령어 뒤에 파일명을 지정하면 tsconfig.json이 무시되므로 주의해야 한다.
$ tsc person # ✘ tsconfig.json이 무시된다.
tsconfig.json을 적용하려면 아래와 같이 트랜스파일링하도록 한다.
$ tsc
위와 같이 파일명을 지정하지 않으면 프로젝트 폴더 내의 모든 TypeScript 파일이 모두 트랜스파일링된다. 상속 관계에 있는 두 개의 TypeScript class를 작성해본다.
// person.ts
export class Person {
protected name: string;
constructor(name: string) {
this.name = name;
}
sayHello() {
return 'Hello, ' + this.name;
}
}
// student.ts
import { Person } from './person';
class Student extends Person {
study(): string {
return `${this.name} is studying.`;
}
}
const student = new Student('Lee');
console.log(student.sayHello());
console.log(student.study());
코드 작성을 마쳤으면 다음 명령으로 두 개의 TypeScript 파일을 한번에 트랜스파일링한다.
$ tsc
$ node student
Hello, Lee
Lee is studying.
-watch
또는 w
옵션을 사용하면 트랜스파일링 대상 파일의 내용이 변경되었을 때 이를 감지하여 자동으로 트랜스파일링이 실행된다.$ tsc --watch
21:23:30 - Compilation complete. Watching for file changes.
또는 아래와 같이 tsconfig.json에 watch 옵션을 추가할 수도 있다.
{
// ...
"watch": true
}
student.ts를 변경해 watch 기능이 동작하는지 확인해 본다.
// student.ts
import { Person } from './person';
class Student extends Person {
study(): string {
return `${this.name} is studying!!`; // studying. → studying!!
}
}
const student = new Student('Lee');
console.log(student.sayHello());
console.log(student.study());
아래와 같이 파일 변경이 감지되고 자동으로 트랜스파일링이 실행된다.
[오전 12:40:06] File change detected. Starting incremental compilation...
[오전 12:40:07] Found 0 errors. Watching for file changes.
$ node student
Hello, Lee
Lee is studying!!
컴파일러의 옵션에 대해서는 TypeScript Compiler Options을 참조하면 된다.
추가적으로 TypeScript 프로젝트에 ESLint/Prettier를 적용하는 방법과 Webpack과 함께 TypeScript를 사용하는 방법에 대해서는 아래 링크를 참고하면 된다.
VSCode 설치는 간단하다. 자신의 OS에 맞는 인스톨러를 다운로드하여 설치하도록 하자.
VSCode를 설치가 완료되었으면 적당한 위치에 프로젝트 폴더를 생성한다. 좌측 맨위의 파일 모양의 “탐색기” 아이콘을 선택하면 프로젝트 폴더를 선택할 수 있는 버튼이 표시된다.
“폴더 열기” 버튼을 클릭하고 적당한 위치에 프로젝트 폴더를 생성한다.
컴파일할 때마다 다양한 옵션을 반복적으로 지정하는 것은 번거러운 일이므로 tsconfig.json을 사용하는 편이 좋다. tsconfig.json은 TypeScript를 위한 프로젝트 단위의 환경 파일로써 컴파일 옵션과 컴파일 대상에 대한 설정 등을 기술한 것이다.
compilerOptions
프로퍼티에는 컴파일 옵션을 설정한다. 생략한 경우에는 기본 컴파일 옵션이 사용된다.
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"sourceMap": true
}
}
컴파일 대상 파일을 지정하기 위해서 files
또는 include
프로퍼티를 사용한다. 만약 files 프로퍼티를 정의하였다면 include 프로퍼티는 무시된다.
files
프로퍼티에는 컴파일 대상 파일의 상대 경로 또는 절대 경로를 명시적으로 설정한다.
{
"files": ["src/file1.ts", "src/file2.ts"]
}
include
프로퍼티에는 컴파일 대상 파일 리스트를 설정한다. exclude
프로퍼티에는 컴파일 대상에서 제외할 파일 리스트를 설정한다.
{
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.spec.ts"]
}
src/\*\*/\*
는 src 폴더 내에 있는 모든 서브 폴더 내의 모든 파일(.ts, .tsx)을 의미한다. 컴파일 옵션 "allowJs": true
를 설정하면 .js와 .jsx 파일도 컴파일 대상이 된다.
이제 프로젝트 폴더에 tsconfig.json 파일을 생성해 본다. tsconfig.json에 아래와 같이 컴파일 설정을 편집한다.
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"sourceMap": true
}
}
이제 간단한 TypeScript 코드를 작성해본다. 파일명은 HelloWorld.ts로 지정한다.
class Startup {
public static main(): number {
console.log('Hello World');
return 0;
}
}
Startup.main();
위 TypeScript 코드를 자바스크립트로 트랜스파일링하기 위해서는 TypeScript 컴파일러를 실행시켜야한다. 컴파일러는 VSCode 외부에 존재하므로 VSCode와 TypeScript 컴파일러 간의 연동이 필요하다.
VSCode는 Task runner로 외부의 툴을 VSCode와 연동시킬 수 있다. CLI로 실행되는 툴들을 VSCode에서 실행시킬 수 있는 수 있도록 하는 것이다.
Ctrl + Shft + P(⇧⌘P)
단축기 또는 메뉴의 보기 > 명령 팔레트를 선택하고 “Configure Task Runner”를 입력한다.
“TypeScript - tsconfig.json”을 선택한다.
.vscode라는 숨겨진 폴더에 아래와 같은 tasks.json
파일이 생성된다.
{
"version": "0.1.0",
"command": "tsc",
"isShellCommand": true,
"args": ["-p", "."],
"showOutput": "silent",
"problemMatcher": "$tsc"
}
이제 ts 파일을 js 파일로 컴파일 해본다. Ctrl + Shft + B(⇧⌘B)
단축키를 누르면 HelloWorld.js와 HelloWorld.js.map이 생성된다.
터미널에서 트랜스파일링된 HelloWorld.js를 실행해본다.
$ node HelloWorld.js
Hello World
보기 > 통합 터미널 (⌃`)을 선택하면 VSCode의 내장 터미널을 사용할 수도 있다.
개발시에는 코드가 빈번히 변경되므로 코드의 변경을 감시하도록 task runner의 설정을 변경해 본다.
아래와 같이 tasks.json
파일을 수정한다.
{
"version": "0.1.0",
"command": "tsc",
"isShellCommand": true,
"args": ["-w", "-p", "."],
"showOutput": "always",
"isWatching": true,
"problemMatcher": "$tsc-watch"
}
Ctrl + Shft + B(⇧⌘B)
단축키로 다시 빌드를 수행한다.
9:09:09 PM - Compilation complete. Watching for file changes.
이제 파일의 변경을 감시하기 시작하며 변경이 발생하면 자동으로 재빌드를 수행한다. ts 파일을 수정해 본다.
class Startup {
public static main(): number {
console.log('Hello Angular2');
return 0;
}
}
Startup.main();
9:17:01 PM - File change detected. Starting incremental compilation...
9:17:02 PM - Compilation complete. Watching for file changes.
터미널에서 트랜스파일링된 HelloWorld.js를 실행하여 파일 변경이 반영된 것을 확인한다.
$ node HelloWorld.js
Hello Angular2
TypeScript를 사용하는 이유는 여러가지 있지만 가장 큰 장점은 다양한 도구의 지원을 받을 수 있다는 것이다. TypeScript는 정적 타입을 지원하므로 높은 수준의 IntelliSense나 리팩토링 등을 지원하며 이러란 도구의 지원은 대규모 프로젝트를 위한 필수적 요소이기도 하다.
프로젝트 내에는 필수적으로 다양한 라이브러리가 포함되는데 이 라이브러리들은 JavaScript로 작성되어있다. TypeScript는 ES5의 Superset(상위확장)이므로 JavaScript를 그대로 사용할 수 있다. 하지만 정적 타입이 없는 JavaScript를 그대로 사용하면 VSCode에서 제공하는 IntelliSense와 같은 다양한 도구의 지원을 받을 수 없다.
따라서 외부 JavaScript 라이브러리에 대해서도 타입체크를 수행하려면 해당 라이브러리의 타입이 정의되어 있는 정의 파일(Definition file)을 제공해야 한다.
라이브러리의 정의 파일을 직접 수작업으로 만드는 것은 어려운 일이다. 다행스럽게도 npm에서 정의 파일을 설치할 수 있다.
위의 예제에서 유틸리티 라이브러리 lodash를 사용해 본다.
우선 lodash를 설치한다.
$ npm init -y
$ npm install lodash --save
npm에서 lodash 정의 파일을 설치한다.
$ npm install @types/lodash --save-dev
ts 파일을 수정해 본다.
import * as _ from 'lodash';
class Startup {
public static main(): number {
const group = _.groupBy(['one', 'two', 'three'], 'length');
console.log(group); // => { '3': ['one', 'two'], '5': ['three'] }
return 0;
}
}
Startup.main();
이제 lodash 라이브러리에 대해 IntelliSense가 작동하는 것을 확인할 수 있다.
디버깅을 위해서는 좌측 상단의 벌레 모양 아이콘을 클릭한 후 화면 상단의 디버그 버튼을 클릭하면 launch.json
파일이 생성된다.
configurations 프로퍼티의 program 프로퍼티값을 디버깅할 파일명으로 변경한다.
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "프로그램 시작",
"program": "${workspaceRoot}/HelloWorld.ts",
"cwd": "${workspaceRoot}",
"outFiles": [],
"sourceMaps": true
},
{
"type": "node",
"request": "attach",
"name": "프로세스에 연결",
"port": 5858,
"outFiles": [],
"sourceMaps": true
}
]
}
중단점을 설정하고 디버그 버튼을 클릭하면 디버깅이 시작된다.
VSCode에서의 TypeScript의 사용에 대한 보다 자세한 내용은 Visual Studio Code: Editing TypeScript을 참조하면 된다.
TypeScript는 아래와 같이 변수명 뒤에 타입을 명시하는 것으로 타입을 선언할 수 있다.
// 변수 foo는 string 타입이다.
let foo: string = 'hello';
선언한 타입에 맞지 않는 값을 할당하면 컴파일 시점에 에러가 발생한다.
let bar: number = true; // error TS2322: Type 'true' is not assignable to type 'number'.
이러한 타입 선언은 개발자가 코드를 예측할 수 있도록 돕는다. 또한 타입 선언은 강력한 타입 체크를 가능하게 하여 문법 에러나 타입과 일치하지 않는 값의 할당 등 기본적인 오류를 런타임 이전에 검출한다. 비주얼스튜디오 코드(VS Code)와 같은 도구을 사용하면 코드를 작성하는 시점에 에러를 검출할 수 있어서 개발 효율이 대폭 향상된다.
함수의 매개변수와 반환값에 대한 타입 선언 방법은 아래와 같다. 일반 변수와 마찬가지로 선언된 타입에 일치하지 않는 값이 주어지면 에러가 발생한다.
// 함수선언식
function multiply1(x: number, y: number): number {
return x * y;
}
// 함수표현식
const multiply2 = (x: number, y: number): number => x * y;
console.log(multiply1(10, 2));
console.log(multiply2(10, 3));
console.log(multiply1(true, 1)); // error TS2345: Argument of type 'true' is not assignable to parameter of type 'number'.
TypeScript는 ES5, ES6의 Superset(상위확장)이므로 [자바스크립트의 타입]을 그대로 사용할 수 있다. 자바스크립트의 타입 이외에도 TypeScript 고유의 타입이 추가로 제공된다.
Type | JS | TS | Description |
---|---|---|---|
boolean | ◯ | ◯ | true와 false |
null | ◯ | ◯ | 값이 없다는 것을 명시 |
undefined | ◯ | ◯ | 값을 할당하지 않은 변수의 초기값 |
number | ◯ | ◯ | 숫자(정수와 실수, Infinity, NaN) |
string | ◯ | ◯ | 문자열 |
symbol | ◯ | ◯ | 고유하고 수정 불가능한 데이터 타입이며 주로 객체 프로퍼티들의 식별자로 사용(ES6에서 추가) |
object | ◯ | ◯ | 객체형(참조형) |
array | ◯ | 배열 | |
tuple | ◯ | 고정된 요소수 만큼의 타입을 미리 선언후 배열을 표현 | |
enum | ◯ | 열거형. 숫자값 집합에 이름을 지정한 것이다. | |
any | ◯ | 타입 추론(type inference)할 수 없거나 타입 체크가 필요없는 변수에 사용. var 키워드로 선언한 변수와 같이 어떤 타입의 값이라도 할당 가능. | |
void | ◯ | 일반적으로 함수에서 반환값이 없을 경우 사용한다. | |
never | ◯ | 결코 발생하지 않는 값 |
다양한 타입을 사전 선언하는 방법은 아래와 같다.
// boolean
let isDone: boolean = false;
// null
let n: null = null;
// undefined
let u: undefined = undefined;
// number
let decimal: number = 6;
let hex: number = 0xf00d;
let binary: number = 0b1010;
let octal: number = 0o744;
// string
let color: string = 'blue';
color = 'red';
let myName: string = `Lee`; // ES6 템플릿 문자열
let greeting: string = `Hello, my name is ${myName}.`; // ES6 템플릿 대입문
// object
const obj: object = {};
// array
let list1: any[] = [1, 'two', true];
let list2: number[] = [1, 2, 3];
let list3: Array<number> = [1, 2, 3]; // 제네릭 배열 타입
// tuple : 고정된 요소수 만큼의 타입을 미리 선언후 배열을 표현
let tuple: [string, number];
tuple = ['hello', 10]; // OK
tuple = [10, 'hello']; // Error
tuple = ['hello', 10, 'world', 100]; // Error
tuple.push(true); // Error
// enum : 열거형은 숫자값 집합에 이름을 지정한 것이다.
enum Color1 {
Red,
Green,
Blue,
}
let c1: Color1 = Color1.Green;
console.log(c1); // 1
enum Color2 {
Red = 1,
Green,
Blue,
}
let c2: Color2 = Color2.Green;
console.log(c2); // 2
enum Color3 {
Red = 1,
Green = 2,
Blue = 4,
}
let c3: Color3 = Color3.Blue;
console.log(c3); // 4
/*
any: 타입 추론(type inference)할 수 없거나 타입 체크가 필요 없는 변수에 사용한다.
var 키워드로 선언한 변수와 같이 어떤 타입의 값이라도 할당할 수 있다.
*/
let notSure: any = 4;
notSure = 'maybe a string instead';
notSure = false; // okay, definitely a boolean
// void : 일반적으로 함수에서 반환값이 없을 경우 사용한다.
function warnUser(): void {
console.log('This is my warning message');
}
// never : 결코 발생하지 않는 값
function infiniteLoop(): never {
while (true) {}
}
function error(message: string): never {
throw new Error(message);
}
타입은 소문자, 대문자를 구별하므로 주의가 필요하다. 위에서 살펴본 바와 같이 TypeScript가 기본 제공하는 타입은 모두 소문자이다. 아래 코드를 살펴본다.
// string: 원시 타입 문자열 타입
let primiteveStr: string;
primiteveStr = 'hello'; // OK
// 원시 타입 문자열 타입에 객체를 할당하였다.
primiteveStr = new String('hello'); // Error
/*
Type 'String' is not assignable to type 'string'.
'string' is a primitive, but 'String' is a wrapper object. Prefer using 'string' when possible.
*/
// String: String 생성자 함수로 생성된 String 래퍼 객체 타입
let objectStr: String;
objectStr = 'hello'; // OK
objectStr = new String('hello'); // OK
string 타입은 TypeScript가 기본으로 제공하는 원시 타입인 문자열 타입을 의미한다. 하지만 대문자로 시작하는 String 타입은 String 생성자 함수로 생성된 String 래퍼 객체 타입을 의미한다. 따라서 string 타입에 String 타입을 할당하면 에러가 발생한다. 하지만 String 타입에는 string 타입을 할당할 수 있다. 이처럼 객체의 유형도 타입이 될 수 있다.
// Date 타입
const today: Date = new Date();
// HTMLElement 타입
const elem: HTMLElement = document.getElementById('myId');
class Person {}
// Person 타입
const person: Person = new Person();
C나 Java같은 C-family 언어는 변수를 선언할 때 변수에 할당할 값의 타입에 따라 사전에 타입을 명시적으로 선언(Type declaration)하여야 하며 선언한 타입에 맞는 값을 할당해야 한다. 이를 정적 타이핑(Static Typing)이라 한다.
자바스크립트는 동적 타입(dynamic typed) 언어 혹은 느슨한 타입(loosely typed) 언어이다. 이것은 변수의 타입 선언 없이 값이 할당되는 과정에서 동적으로 타입을 추론(Type Inference)한다는 의미이다. 동적 타입 언어는 타입 추론에 의해 변수의 타입이 결정된 후에도 같은 변수에 여러 타입의 값을 교차하여 할당할 수 있다. 이를 동적 타이핑(Dynamic Typing)이라 한다.
동적 타이핑은 사용하기 간편하지만 코드를 예측하기 힘들어 예상치 못한 오류를 만들 가능성이 높다. 또한 IDE와 같은 도구가 변수나 매개 변수, 함수의 반환값의 타입을 알 수 없어 코드 어시스트 등의 기능을 지원할 수 없게 한다.
var foo;
console.log(typeof foo); // undefined
foo = null;
console.log(typeof foo); // object
foo = {};
console.log(typeof foo); // object
foo = 3;
console.log(typeof foo); // number
foo = 3.14;
console.log(typeof foo); // number
foo = 'Hi there';
console.log(typeof foo); // string
foo = true;
console.log(typeof foo); // boolean
TypeScript의 가장 독특한 특징은 정적 타이핑을 지원한다는 것이다. 정적 타입 언어는 타입을 명시적으로 선언하며, 타입이 결정된 후에는 타입을 변경할 수 없다. 잘못된 타입의 값이 할당 또는 반환되면 컴파일러는 이를 감지해 에러를 발생시킨다.
let foo: string, // 문자열 타입
bar: number, // 숫자 타입
baz: boolean; // 불리언 타입
foo = 'Hello';
bar = 123;
baz = 'true'; // error: Type '"true"' is not assignable to type 'boolean'.
정적 타이핑은 변수는 물론 함수의 매개변수와 반환값에도 사용할 수 있다.
function add(x: number, y: number): number {
return x + y;
}
console.log(add(10, 10)); // 20
console.log(add('10', '10'));
// error TS2345: Argument of type '"10"' is not assignable to parameter of type 'number'.
참고로 정적 타이핑과 동적 타이핑 중 무엇이 우위인지에 대한 논쟁은 사실 큰 의미가 없다. 정적 타이핑과 동적 타이핑의 가장 큰 차이를 컴파일 시의 에러 검출과 런타임 시의 에러 검출로 볼 수 있는데 Java와 같은 정적 타이핑 언어도 런타임에만 검출되는 에러가 존재하기 때문이다.
정적 타이핑의 장점은 코드 가독성, 예측성, 안정성의 향상이라고 볼 수 있는데 이는 대규모 프로젝트에 매우 적합하다.
만약 타입 선언을 생략하면 값이 할당되는 과정에서 동적으로 타입이 결정된다. 이를 타입 추론(Type Inference)이라 한다.
let foo = 123; // foo는 number 타입
위 코드를 보면 변수 foo에 타입을 선언하지 않았으나 타입 추론에 의해 변수의 타입이 결정된다. 동적 타입 언어는 타입 추론에 의해 변수의 타입이 결정된 후에도 같은 변수에 여러 타입의 값을 교차하여 할당할 수 있다. 하지만 정적 타입 언어는 타입이 결정된 후에는 타입을 변경할 수 없다. TypeScript는 정적 타입 언어이므로 타입 추론으로 타입이 결정된 이후, 다른 타입의 값을 할당하면 에러가 발생한다.
let foo = 123; // foo는 number 타입
foo = 'hi'; // error: Type '"hi"' is not assignable to type 'number'.
타입 선언을 생략하고 값도 할당하지 않아서 타입을 추론할 수 없으면 any
타입이 된다. any
타입의 변수는 자바스크립트의 var 키워드로 선언된 변수처럼 어떤 타입의 값도 재할당이 가능하다. 이는 TypeScript를 사용하는 장점을 없애기 때문에 사용하지 않는 편이 좋다.
let foo; // let foo: any와 동치
foo = 'Hello';
console.log(typeof foo); // string
foo = true;
console.log(typeof foo); // boolean
기존의 타입에서 다른 타입으로 타입 캐스팅하려면 as 키워드를 사용하거나 <>
연산자를 사용할 수 있다. 다음 예제를 살펴본다.
const $input = document.querySelector('input["type="text"]');
// => $input: Element | null
const val = $input.value;
// TS2339: Property 'value' does not exist on type 'Element'.
document.querySelector 메서드는 Element | null 타입의 값을 반환한다. 따라서 위 예제의 input.value`를 실행하면 Element 또는 null 타입에는 value이란 프로퍼티가 존재하지 않으므로 컴파일 에러가 발생한다.
value 프로퍼티는 Element 타입의 하위 타입인 HTMLInputElement 타입에만 존재한다. 따라서 다음과 같이 타입 캐스팅이 필요하다.
const $input = document.querySelector('input["type="text"]') as HTMLInputElement;
const val = $input.value;
또는 타입 캐스팅을 위해 as 키워드 대신 <>
연산자를 사용할 수도 있다.
const $input = <HTMLInputElement>document.querySelector('input["type="text"]');
const val = $input.value;
[ES6에서 새롭게 도입된 클래스]는 기존 프로토타입 기반 객체지향 언어보다 클래스 기반 언어에 익숙한 개발자가 보다 빠르게 학습할 수 있는 단순명료한 새로운 문법을 제시하고 있다. 하지만 클래스가 새로운 객체지향 모델을 제공하는 것은 아니다. 사실 클래스도 함수이고 기존 프로토타입 기반 패턴의 Syntactic sugar일 뿐이다. Typescript가 지원하는 클래스는 ECMAScript 6의 클래스와 상당히 유사하지만 몇 가지 Typescript만의 고유한 확장 기능이 있다.
[ES6 클래스]는 클래스 몸체에 메소드만을 포함할 수 있다. 클래스 몸체에 클래스 프로퍼티를 선언할 수 없고 반드시 생성자 내부에서 클래스 프로퍼티를 선언하고 초기화한다.
// person.js
class Person {
constructor(name) {
// 클래스 프로퍼티의 선언과 초기화
this.name = name;
}
walk() {
console.log(`${this.name} is walking.`);
}
}
위 예제는 ES6에서 문제없이 실행되는 코드이지만 위 파일의 확장자를 ts로 바꾸어 Typescript 파일로 변경한 후, 컴파일하면 아래와 같이 컴파일 에러가 발생한다.
person.ts(4,10): error TS2339: Property 'name' does not exist on type 'Person'.
person.ts(8,25): error TS2339: Property 'name' does not exist on type 'Person'.
Typescript 클래스는 클래스 몸체에 클래스 프로퍼티를 사전 선언하여야 한다.
// person.ts
class Person {
// 클래스 프로퍼티를 사전 선언하여야 한다
name: string;
constructor(name: string) {
// 클래스 프로퍼티수에 값을 할당
this.name = name;
}
walk() {
console.log(`${this.name} is walking.`);
}
}
const person = new Person('Lee');
person.walk(); // Lee is walking
Typescript 클래스는 클래스 기반 객체 지향 언어가 지원하는 접근 제한자(Access modifier) public, private, protected 를 지원하며 의미 또한 기본적으로 동일하다.
단, 접근 제한자를 명시하지 않았을 때, 다른 클래스 기반 언어의 경우, 암묵적으로 protected로 지정되어 패키지 레벨로 공개되지만 Typescript의 경우, 접근 제한자를 생략한 클래스 프로퍼티와 메소드는 암묵적으로 public이 선언된다. 따라서 public으로 지정하고자 하는 멤버 변수와 메소드는 접근 제한자를 생략한다.
접근 제한자를 선언한 프로퍼티와 메소드에 대한 접근 가능성은 아래와 같다.
접근 가능성 | public | protected | private |
---|---|---|---|
클래스 내부 | ◯ | ◯ | ◯ |
자식 클래스 내부 | ◯ | ◯ | ✕ |
클래스 인스턴스 | ◯ | ✕ | ✕ |
아래의 예제를 통해 접근 제한자가 선언된 프로퍼티로의 접근 가능성에 대해 살펴본다.
class Foo {
public x: string;
protected y: string;
private z: string;
constructor(x: string, y: string, z: string) {
// public, protected, private 접근 제한자 모두 클래스 내부에서 참조 가능하다.
this.x = x;
this.y = y;
this.z = z;
}
}
const foo = new Foo('x', 'y', 'z');
// public 접근 제한자는 클래스 인스턴스를 통해 클래스 외부에서 참조 가능하다.
console.log(foo.x);
// protected 접근 제한자는 클래스 인스턴스를 통해 클래스 외부에서 참조할 수 없다.
console.log(foo.y);
// error TS2445: Property 'y' is protected and only accessible within class 'Foo' and its subclasses.
// private 접근 제한자는 클래스 인스턴스를 통해 클래스 외부에서 참조할 수 없다.
console.log(foo.z);
// error TS2341: Property 'z' is private and only accessible within class 'Foo'.
class Bar extends Foo {
constructor(x: string, y: string, z: string) {
super(x, y, z);
// public 접근 제한자는 자식 클래스 내부에서 참조 가능하다.
console.log(this.x);
// protected 접근 제한자는 자식 클래스 내부에서 참조 가능하다.
console.log(this.y);
// private 접근 제한자는 자식 클래스 내부에서 참조할 수 없다.
console.log(this.z);
// error TS2341: Property 'z' is private and only accessible within class 'Foo'.
}
}
접근 제한자는 생성자 파라미터에도 선언할 수 있다. 이때 접근 제한자가 사용된 생성자 파라미터는 암묵적으로 클래스 프로퍼티로 선언되고 생성자 내부에서 별도의 초기화가 없어도 암묵적으로 초기화가 수행된다.
이때 private 접근 제한자가 사용되면 클래스 내부에서만 참조 가능하고 public 접근 제한자가 사용되면 클래스 외부에서도 참조가 가능하다.
class Foo {
/*
접근 제한자가 선언된 생성자 파라미터 x는 클래스 프로퍼티로 선언되고 지동으로 초기화된다.
public이 선언되었으므로 x는 클래스 외부에서도 참조가 가능하다.
*/
constructor(public x: string) {}
}
const foo = new Foo('Hello');
console.log(foo); // Foo { x: 'Hello' }
console.log(foo.x); // Hello
class Bar {
/*
접근 제한자가 선언된 생성자 파라미터 x는 멤버 변수로 선언되고 자동으로 초기화된다.
private이 선언되었으므로 x는 클래스 내부에서만 참조 가능하다.
*/
constructor(private x: string) {}
}
const bar = new Bar('Hello');
console.log(bar); // Bar { x: 'Hello' }
// private이 선언된 bar.x는 클래스 내부에서만 참조 가능하다
console.log(bar.x); // Property 'x' is private and only accessible within class 'Bar'.
만일 생성자 파라미터에 접근 제한자를 선언하지 않으면 생성자 파라미터는 생성자 내부에서만 유효한 지역 변수가 되어 생성자 외부에서 참조가 불가능하게 된다.
class Foo {
// x는 생성자 내부에서만 유효한 지역 변수이다.
constructor(x: string) {
console.log(x);
}
}
const foo = new Foo('Hello');
console.log(foo); // Foo {}
Typescript는 readonly
키워드를 사용할 수 있다. readonly가 선언된 클래스 프로퍼티는 선언 시 또는 생성자 내부에서만 값을 할당할 수 있다. 그 외의 경우에는 값을 할당할 수 없고 오직 읽기만 가능한 상태가 된다. 이를 이용하여 상수의 선언에 사용한다.
class Foo {
private readonly MAX_LEN: number = 5;
private readonly MSG: string;
constructor() {
this.MSG = 'hello';
}
log() {
// readonly가 선언된 프로퍼티는 재할당이 금지된다.
this.MAX_LEN = 10; // Cannot assign to 'MAX_LEN' because it is a constant or a read-only property.
this.MSG = 'Hi'; // Cannot assign to 'MSG' because it is a constant or a read-only property.
console.log(`MAX_LEN: ${this.MAX_LEN}`); // MAX_LEN: 5
console.log(`MSG: ${this.MSG}`); // MSG: hello
}
}
new Foo().log();
ES6 클래스에서 static 키워드는 클래스의 정적(static) 메소드를 정의한다. 정적 메소드는 클래스의 인스턴스가 아닌 클래스 이름으로 호출한다. 따라서 클래스의 인스턴스를 생성하지 않아도 호출할 수 있다.
class Foo {
constructor(prop) {
this.prop = prop;
}
static staticMethod() {
/*
정적 메소드는 this를 사용할 수 없다.
정적 메소드 내부에서 this는 클래스의 인스턴스가 아닌 클래스 자신을 가리킨다.
*/
return 'staticMethod';
}
prototypeMethod() {
return this.prop;
}
}
// 정적 메소드는 클래스 이름으로 호출한다.
console.log(Foo.staticMethod());
const foo = new Foo(123);
// 정적 메소드는 인스턴스로 호출할 수 없다.
console.log(foo.staticMethod()); // Uncaught TypeError: foo.staticMethod is not a function
Typescript에서는 static 키워드를 클래스 프로퍼티에도 사용할 수 있다. 정적 메소드와 마찬가지로 정적 클래스 프로퍼티는 인스턴스가 아닌 클래스 이름으로 호출하며 클래스의 인스턴스를 생성하지 않아도 호출할 수 있다.
class Foo {
// 생성된 인스턴스의 갯수
static instanceCounter = 0;
constructor() {
// 생성자가 호출될 때마다 카운터를 1씩 증가시킨다.
Foo.instanceCounter++;
}
}
var foo1 = new Foo();
var foo2 = new Foo();
console.log(Foo.instanceCounter); // 2
console.log(foo2.instanceCounter); // error TS2339: Property 'instanceCounter' does not exist on type 'Foo'.
추상 클래스(abstract class)는 하나 이상의 추상 메소드를 포함하며 일반 메소드도 포함할 수 있다. 추상 메소드는 내용이 없이 메소드 이름과 타입만이 선언된 메소드를 말하며 선언할 때 abstract 키워드를 사용한다. 추상 클래스를 정의할 때는 abstract 키워드를 사용하며, 직접 인스턴스를 생성할 수 없고 상속만을 위해 사용된다. 추상 클래스를 상속한 클래스는 추상 클래스의 추상 메소드를 반드시 구현하여야 한다.
abstract class Animal {
// 추상 메소드
abstract makeSound(): void;
// 일반 메소드
move(): void {
console.log('roaming the earth...');
}
}
// 직접 인스턴스를 생성할 수 없다.
// new Animal();
// error TS2511: Cannot create an instance of the abstract class 'Animal'.
class Dog extends Animal {
// 추상 클래스를 상속한 클래스는 추상 클래스의 추상 메소드를 반드시 구현하여야 한다
makeSound() {
console.log('bowwow~~');
}
}
const myDog = new Dog();
myDog.makeSound();
myDog.move();
[인터페이스]는 모든 메소드가 추상 메소드이지만 추상 클래스는 하나 이상의 추상 메소드와 일반 메소드를 포함할 수 있다.
인터페이스는 일반적으로 타입 체크를 위해 사용되며 변수, 함수, 클래스에 사용할 수 있다. 인터페이스는 여러가지 타입을 갖는 프로퍼티로 이루어진 새로운 타입을 정의하는 것과 유사하다. 인터페이스에 선언된 프로퍼티 또는 메소드의 구현을 강제하여 일관성을 유지할 수 있도록 하는 것이다. ES6는 인터페이스를 지원하지 않지만 TypeScript는 인터페이스를 지원한다.
인터페이스는 프로퍼티와 메소드를 가질 수 있다는 점에서 클래스와 유사하나 직접 인스턴스를 생성할 수 없고 모든 메소드는 추상 메소드이다. 단, 추상 클래스의 추상 메소드와 달리 abstract 키워드를 사용하지 않는다.
인터페이스는 변수의 타입으로 사용할 수 있다. 이때 인터페이스를 타입으로 선언한 변수는 해당 인터페이스를 준수하여야 한다. 이것은 새로운 타입을 정의하는 것과 유사하다.
// 인터페이스의 정의
interface Todo {
id: number;
content: string;
completed: boolean;
}
// 변수 todo의 타입으로 Todo 인터페이스를 선언하였다.
let todo: Todo;
// 변수 todo는 Todo 인터페이스를 준수하여야 한다.
todo = { id: 1, content: 'typescript', completed: false };
인터페이스를 사용하여 함수 파라미터의 타입을 선언할 수 있다. 이때 해당 함수에는 함수 파라미터의 타입으로 지정한 인터페이스를 준수하는 인수를 전달하여야 한다. 함수에 객체를 전달할 때 복잡한 매개변수 체크가 필요없어서 매우 유용하다.
// 인터페이스의 정의
interface Todo {
id: number;
content: string;
completed: boolean;
}
let todos: Todo[] = [];
// 파라미터 todo의 타입으로 Todo 인터페이스를 선언하였다.
function addTodo(todo: Todo) {
todos = [...todos, todo];
}
// 파라미터 todo는 Todo 인터페이스를 준수하여야 한다.
const newTodo: Todo = { id: 1, content: 'typescript', completed: false };
addTodo(newTodo);
console.log(todos);
// [ { id: 1, content: 'typescript', completed: false } ]
인터페이스는 함수의 타입으로 사용할 수 있다. 이때 함수의 인터페이스에는 타입이 선언된 파라미터 리스트와 리턴 타입을 정의한다. 함수 인테페이스를 구현하는 함수는 인터페이스를 준수하여야 한다.
// 함수 인터페이스의 정의
interface SquareFunc {
(num: number): number;
}
// 함수 인테페이스를 구현하는 함수는 인터페이스를 준수하여야 한다.
const squareFunc: SquareFunc = function (num: number) {
return num * num;
};
console.log(squareFunc(10)); // 100
클래스 선언문의 implements 뒤에 인터페이스를 선언하면 해당 클래스는 지정된 인터페이스를 반드시 구현하여야 한다. 이는 인터페이스를 구현하는 클래스의 일관성을 유지할 수 있는 장점을 갖는다. 인터페이스는 프로퍼티와 메소드를 가질 수 있다는 점에서 클래스와 유사하나 직접 인스턴스를 생성할 수는 없다.
// 인터페이스의 정의
interface ITodo {
id: number;
content: string;
completed: boolean;
}
// Todo 클래스는 ITodo 인터페이스를 구현하여야 한다.
class Todo implements ITodo {
constructor(public id: number, public content: string, public completed: boolean) {}
}
const todo = new Todo(1, 'Typescript', false);
console.log(todo);
인터페이스는 프로퍼티뿐만 아니라 메소드도 포함할 수 있다. 단, 모든 메소드는 추상 메소드이어야 한다. 인터페이스를 구현하는 클래스는 인터페이스에서 정의한 프로퍼티와 추상 메소드를 반드시 구현하여야 한다.
// 인터페이스의 정의
interface IPerson {
name: string;
sayHello(): void;
}
/*
인터페이스를 구현하는 클래스는 인터페이스에서 정의한 프로퍼티와 추상 메소드를 반드시 구현하여야 한다.
*/
class Person implements IPerson {
// 인터페이스에서 정의한 프로퍼티의 구현
constructor(public name: string) {}
// 인터페이스에서 정의한 추상 메소드의 구현
sayHello() {
console.log(`Hello ${this.name}`);
}
}
function greeter(person: IPerson): void {
person.sayHello();
}
const me = new Person('Lee');
greeter(me); // Hello Lee
주의해야 할 것은 인터페이스를 구현하였다는 것만이 타입 체크를 통과하는 유일한 방법은 아니다. 타입 체크에서 중요한 것은 값을 실제로 가지고 있는 것이다. 이해가 어려울 수 있으므로 예를 들어 설명한다.
interface IDuck {
// 1
quack(): void;
}
class MallardDuck implements IDuck {
// 3
quack() {
console.log('Quack!');
}
}
class RedheadDuck {
// 4
quack() {
console.log('q~uack!');
}
}
function makeNoise(duck: IDuck): void {
// 2
duck.quack();
}
makeNoise(new MallardDuck()); // Quack!
makeNoise(new RedheadDuck()); // q~uack! // 5
(1) 인터페이스 IDuck은 quack 메소드를 정의하였다.
(2) makeNoise 함수는 인터페이스 IDuck을 구현한 클래스의 인스턴스 duck을 인자로 전달받는다.
(3) 클래스 MallardDuck은 인터페이스 IDuck을 구현하였다.
(4) 클래스 RedheadDuck은 인터페이스 IDuck을 구현하지는 않았지만 quack 메소드를 갖는다.
(5) makeNoise 함수에 인터페이스 IDuck을 구현하지 않은 클래스 RedheadDuck의 인스턴스를 인자로 전달하여도 에러 없이 처리된다.
TypeScript는 해당 인터페이스에서 정의한 프로퍼티나 메소드를 가지고 있다면 그 인터페이스를 구현한 것으로 인정한다. 이것을 덕 타이핑(duck typing) 또는 구조적 타이핑(structural typing)이라 한다.
인터페이스를 변수에 사용할 경우에도 덕 타이핑은 적용된다.
interface IPerson {
name: string;
}
function sayHello(person: IPerson): void {
console.log(`Hello ${person.name}`);
}
const me = { name: 'Lee', age: 18 };
sayHello(me); // Hello Lee
변수 me는 인터페이스 IPerson과 일치하지는 않는다. 하지만 IPerson의 name 프로퍼티를 가지고 있으면 인터페이스에 부합하는 것으로 인정된다.
인터페이스는 개발 단계에서 도움을 주기 위해 제공되는 기능으로 자바스크립트의 표준이 아니다. 따라서 위 예제의 TypeScript 파일을 자바스크립트 파일로 트랜스파일링하면 아래와 같이 인터페이스가 삭제된다.
function sayHello(person) {
console.log('Hello ' + person.name);
}
var me = { name: 'Lee', age: 18 };
sayHello(me); // Hello Lee
인터페이스의 프로퍼티는 반드시 구현되어야 한다. 하지만 인터페이스의 프로퍼티가 선택적으로 필요한 경우가 있을 수 있다. 선택적 프로퍼티(Optional Property)는 프로퍼티명 뒤에 ?
를 붙이며 생략하여도 에러가 발생하지 않는다.
interface UserInfo {
username: string;
password: string;
age?: number;
address?: string;
}
const userInfo: UserInfo = {
username: 'ungmo2@gmail.com',
password: '123456',
};
console.log(userInfo);
이렇게 선택적 프로퍼티를 사용하면 사용 가능한 프로퍼티를 파악할 수 있어서 코드를 이해하기 쉬워진다.
인터페이스는 extends 키워드를 사용하여 인터페이스 또는 클래스를 상속받을 수 있다.
interface Person {
name: string;
age?: number;
}
interface Student extends Person {
grade: number;
}
const student: Student = {
name: 'Lee',
age: 20,
grade: 3,
};
복수의 인터페이스를 상속받을 수도 있다.
interface Person {
name: string;
age?: number;
}
interface Developer {
skills: string[];
}
interface WebDeveloper extends Person, Developer {}
const webDeveloper: WebDeveloper = {
name: 'Lee',
age: 20,
skills: ['HTML', 'CSS', 'JavaScript'],
};
인터페이스는 인터페이스 뿐만 아니라 클래스도 상속받을 수 있다. 단, 클래스의 모든 멤버(public, protected, private)가 상속되지만 구현까지 상속하지는 않는다.
class Person {
constructor(public name: string, public age: number) {}
}
interface Developer extends Person {
skills: string[];
}
const developer: Developer = {
name: 'Lee',
age: 20,
skills: ['HTML', 'CSS', 'JavaScript'],
};
타입 앨리어스는 새로운 타입을 정의한다. 타입으로 사용할 수 있다는 점에서 타입 앨리어스는 인터페이스와 유사하다.
인터페이스는 아래와 같이 타입으로 사용할 수 있다.
interface Person {
name: string;
age?: number;
}
// 빈 객체를 Person 타입으로 지정
const person = {} as Person;
person.name = 'Lee';
person.age = 20;
person.address = 'Seoul'; // Error
타입 앨리어스도 인터페이스와 마찬가지로 타입으로 사용할 수 있다.
// 타입 앨리어스
type Person = {
name: string;
age?: number;
};
// 빈 객체를 Person 타입으로 지정
const person = {} as Person;
person.name = 'Lee';
person.age = 20;
person.address = 'Seoul'; // Error
하지만 타입 앨리어스는 원시값, 유니온 타입, 튜플 등도 타입으로 지정할 수 있다.
// 문자열 리터럴로 타입 지정
type Str = 'Lee';
// 유니온 타입으로 타입 지정
type Union = string | null;
// 문자열 유니온 타입으로 타입 지정
type Name = 'Lee' | 'Kim';
// 숫자 리터럴 유니온 타입으로 타입 지정
type Num = 1 | 2 | 3 | 4 | 5;
// 객체 리터럴 유니온 타입으로 타입 지정
type Obj = { a: 1 } | { b: 2 };
// 함수 유니온 타입으로 타입 지정
type Func = (() => string) | (() => void);
// 인터페이스 유니온 타입으로 타입 지정
type Shape = Square | Rectangle | Circle;
// 튜플로 타입 지정
type Tuple = [string, boolean];
const t: Tuple = ['', '']; // Error
인터페이스는 extends 또는 implements될 수 있지만 타입 앨리어스는 extends 또는 implements될 수 없다. 즉, 상속을 통해 확장이 필요하다면 타입 앨리어스보다는 인터페이스가 유리하다. 하지만 인터페이스로 표현할 수 없거나 유니온 또는 튜플을 사용해야한다면 타입 앨리어스를 사용한는 편이 유리하다.
Java나 C# 같은 정적 타입 언어의 경우, 함수 또는 클래스를 정의하는 시점에 매개변수나 반환값의 타입을 선언하여야 한다. TypeScript 또한 정적 타입 언어이기 때문에 함수 또는 클래스를 정의하는 시점에 매개변수나 반환값의 타입을 선언하여야 한다. 그런데 함수 또는 클래스를 정의하는 시점에 매개변수나 반환값의 타입을 선언하기 어려운 경우가 있다.
아래의 예제를 살펴본다. FIFO(First In First Out) 구조로 데이터를 저장하는 큐를 표현한 것이다.
class Queue {
protected data: any[] = [];
push(item: any) {
this.data.push(item);
}
pop() {
return this.data.shift();
}
}
const queue = new Queue();
queue.push(0);
queue.push('1'); // 의도하지 않은 실수!
console.log(queue.pop().toFixed()); // 0
console.log(queue.pop().toFixed()); // Runtime error
Queue 클래스의 data 프로퍼티는 any[] 타입이다. any[] 타입은 어떤 타입의 요소도 가질 수 있는 배열을 의미한다.
any[] 타입은 배열 요소의 타입이 모두 같지 않다는 문제를 가지게 된다. 위 예제의 경우 data 프로퍼티는 number 타입만을 포함하는 배열이라는 기대 하에 각 요소에 대해 [Number.prototype.toFixed]를 사용하였다. 따라서 number 타입이 아닌 요소의 경우 런타임 에러가 발생한다.
위와 같은 문제를 해결하기 위해 Queue 클래스를 상속하여 number 타입 전용 NumberQueue 클래스를 정의할 수 있다.
class Queue {
protected data: any[] = [];
push(item: any) {
this.data.push(item);
}
pop() {
return this.data.shift();
}
}
// Queue 클래스를 상속하여 number 타입 전용 NumberQueue 클래스를 정의
class NumberQueue extends Queue {
// number 타입의 요소만을 push한다.
push(item: number) {
super.push(item);
}
pop(): number {
return super.pop();
}
}
const queue = new NumberQueue();
queue.push(0);
// 의도하지 않은 실수를 사전 검출 가능
// error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
// queue.push('1');
queue.push(+'1'); // 실수를 사전 인지하고 수정할 수 있다
console.log(queue.pop().toFixed()); // 0
console.log(queue.pop().toFixed()); // 1
이와 같이 number 타입 전용 NumberQueue 클래스를 정의하면 number 타입 이외의 요소가 추가(push)되었을 때, 아래와 같이 런타임 이전에 에러를 사전 감지할 수 있다.
하지만 다양한 타입을 지원해야 한다면 타입 별로 클래스를 상속받아 추가해야 하므로 이 또한 좋은 방법은 아니다. 제네릭을 사용하여 이 문제를 해결하여 본다.
class Queue<T> {
protected data: Array<T> = [];
push(item: T) {
this.data.push(item);
}
pop(): T | undefined {
return this.data.shift();
}
}
// number 전용 Queue
const numberQueue = new Queue<number>();
numberQueue.push(0);
// numberQueue.push('1'); // 의도하지 않은 실수를 사전 검출 가능
numberQueue.push(+'1'); // 실수를 사전 인지하고 수정할 수 있다
// ?. => optional chaining
// https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#optional-chaining
console.log(numberQueue.pop()?.toFixed()); // 0
console.log(numberQueue.pop()?.toFixed()); // 1
console.log(numberQueue.pop()?.toFixed()); // undefined
// string 전용 Queue
const stringQueue = new Queue<string>();
stringQueue.push('Hello');
stringQueue.push('World');
console.log(stringQueue.pop()?.toUpperCase()); // HELLO
console.log(stringQueue.pop()?.toUpperCase()); // WORLD
console.log(stringQueue.pop()?.toUpperCase()); // undefined
// 커스텀 객체 전용 Queue
const myQueue = new Queue<{ name: string; age: number }>();
myQueue.push({ name: 'Lee', age: 10 });
myQueue.push({ name: 'Kim', age: 20 });
console.log(myQueue.pop()); // { name: 'Lee', age: 10 }
console.log(myQueue.pop()); // { name: 'Kim', age: 20 }
console.log(myQueue.pop()); // undefined
제네릭은 선언 시점이 아니라 생성 시점에 타입을 명시하여 하나의 타입만이 아닌 다양한 타입을 사용할 수 있도록 하는 기법이다. 한번의 선언으로 다양한 타입에 재사용이 가능하다는 장점이 있다.
T는 제네릭을 선언할 때 관용적으로 사용되는 식별자로 타입 파라미터(Type parameter)라 한다. T는 Type의 약자로 반드시 T를 사용하여야 하는 것은 아니다.
또한 함수에도 제네릭을 사용할 수 있다. 제네릭을 사용하면 하나의 타입만이 아닌 다양한 타입의 매개변수와 리턴값을 사용할 수 있다. 아래 예제를 살펴본다.
function reverse<T>(items: T[]): T[] {
return items.reverse();
}
reverse 함수는 인수의 타입에 의해 타입 매개변수가 결정된다. Reverse 함수는 다양한 타입의 요소로 구성된 배열을 인자로 전달받는다. 예를 들어 number 타입의 요소를 갖는 배열을 전달받으면 타입 매개변수는 number가 된다.
function reverse<T>(items: T[]): T[] {
return items.reverse();
}
const arg = [1, 2, 3, 4, 5];
// 인수에 의해 타입 매개변수가 결정된다.
const reversed = reverse(arg);
console.log(reversed); // [ 5, 4, 3, 2, 1 ]
만약 {name: string} 타입의 요소를 갖는 배열을 전달받으면 타입 매개변수는 {name: string}가 된다.
function reverse<T>(items: T[]): T[] {
return items.reverse();
}
const arg = [{ name: 'Lee' }, { name: 'Kim' }];
// 인수에 의해 타입 매개변수가 결정된다.
const reversed = reverse(arg);
console.log(reversed); // [ { name: 'Kim' }, { name: 'Lee' } ]
본 포스트는 [웹 프로그래밍 튜토리얼 PoiemaWeb의 TypeScript] 를 정독하며 정리한 글입니다.