Javascript/Typescript

#1 자바스크립트를 정적 타입 언어로

rivercity310 2021. 12. 27. 12:36

타입스크립트란?

타입스크립트는 자바스크립트의 모든 기능을 포함하면서 정적 타입을 지원하는 슈퍼셋 언어입니다.
그렇다면 왜 굳이 타입스크립트를 도입해야 할까요?

타입스크립트는 자바스크립트에서 흔히 발생하는 실수를 방지하며, 리팩터링을 쉽게 만들고, 생산성을 배로 늘려줍니다.

안전하게 프로그래밍할 수 있다는 의미입니다.

여기서 안전한이란 "타입 안전성"을 뜻하는데, 타입을 이용해 프로그램이 유효하지 않은 작업을 수행하지 않도록 방지하는 것을 의미합니다.

 

자바스크립트에서는 실수가 있는 코드라도 최대한 실행하려고 시도합니다.

예를 들어 문자열과 숫자를 더하거나 객체에 존재하지 않는 멤버 함수를 호출(undefined로 평가)하려 해도 예외를 던지지 않고 결과를 도출합니다.

하지만 이러한 기능때문에 코드에 실수를 저지른 시점과 그 실수를 처음 인지하는 시점이 달라지기 쉽습니다.

 

 

 


 

 

 

타입스크립트 컴파일러

전체 개요를 먼저 살펴보겠습니다. 

프로그램은 프로그래머가 작성한 다수의 텍스트 파일로 구성됩니다.

이 텍스트를 컴파일러(Compiler)라는 특별한 프로그램이 파싱하여 추상 문법 트리(abstract syntax tree, AST)라는 자료구조로 변환합니다.

그리고 컴파일러는 다시 AST를 바이트코드(bytecode)라는 하위 수준의 표현으로 변환합니다.

바이트코드가 만들어졌으면 런타임(runtime)이라는 다른 프로그램에 바이트코드를 입력해 평가하고 결과를 얻을 수 있습니다.

 

  • 프로그램이 AST로 파싱된다.
  • AST가 바이트코드로 컴파일된다.
  • 런타임이 바이트코드를 평가한다.

 

 

자세한 과정이나 내용은 조금씩 다를 수 있지만, 대부분의 언어가 이러한 과정으로 프로그램을 실행합니다.

타입스크립트가 다른 언어와 다른 점은 컴파일러가 코드를 바이트코드 대신 자바스크립트 코드로 변환한다는 점입니다.

이후로 일반적인 자바스크립트 코드를 실행하듯이 브라우저나 Node.js로 실행할 수 있습니다.

 

또한 타입스크립트 컴파일러는 AST를 만들기 전에 타입 검사기(typechecker)를 통해 타입 확인을 거칩니다.

아래는 타입스크립트 소스가 자바스크립트로 변환되고 실행되기까지의 과정입니다.

 

  1. 타입스크립트 소스 -> 타입스크립트 AST
  2. 타입 검사기가 AST를 확인
  3. 타입스크립트 AST -> 자바스크립트 소스
  4. 자바스크립트 소스 -> 자바스크립트 AST
  5. AST -> 바이트코드
  6. 런타임이 바이트코드를 평가

위 과정 1~3은 TSC(Typescript Compiler)가 수행하고, 4~6은 브라우저, Node.js, 기타 자바스크립트 엔진 같은 자바스크립트 런타임이 실행합니다.

 

또한 과정 1~2에서는 소스 코드에 사용된 타입을 이용하지만, 그 이후로는 이용하지 않습니다.

다시 말해, TSC가 타입스크립트 코드를 자바스크립트 코드로 컴파일할 때는 개발자가 사용한 타입을 확인하지 않습니다.

따라서 개발자가 코드에 기입한 타입 정보는 최종적으로 만들어지는 프로그램에 아무런 영향을 주지 않고, 단지 타입을 확인하는 용도로만 사용됩니다.

 

 

 

 


 

 

 

타입스크립트 설정

(간단하게 타입스크립트를 테스트 해보고 싶다면 https://www.typescriptlang.org/play에서 할 수 있습니다.)

 

먼저 TSC를 실행하기 위해서는 Node.js가 필요합니다. 

Node.js는 프로젝트의 의존성이나 빌드를 관리하는 패키지 관리자 NPM을 포함합니다.

NPM을 이용해 TSC와 TSLint(Typescript Linter)를 설치하겠습니다.

# 새 디렉터리 생성
mkdir typescript-practice
cd typescript-practice

# 새 NPM 프로젝트 초기화
npm init

# TSC, TSLint, Node.js용 타입 선언 설치
npm install --save-dev typescript tslint @types/node

 

 

tsconfig.json

모든 타입스크립트 프로젝트는 루트 디렉터리에 tsconfig.json이라는 파일이 존재해야 합니다.

tsconfig.json 파일에서 타입스크립트의 프로젝트에서 어떤 파일을 컴파일하고, 어떤 자바스크립트 버전으로 방출하는지 등을 정의합니다.

루트 디렉터리에 tsconfig.json 파일을 만들고 다음과 같이 정의합니다. (tsc --init)

{
    "compilerOptions": {
        "module": "commonjs",
        "target": "ES2015",
        "sourceMap": true,
        "outDir": "dist"
    },
    "include": [
        "src/**/*"
    ],
    "exclude": [
        "node_modules"
    ]
}
/*
- include: TSC가 타입스크립트 파일을 찾을 디렉터리

- lib: TSC가 코드 실행 환경해서 이용할 수 있다고 가정하는 API 
(ES2015의 Object.assign, DOM의 document.querySelector 등)

- module: TSC가 코드를 컴파일할 대상 모듈 시스템
(CommonJS, SystemJS, ES2015 등)

- outDir: 생성된 자바스크립트 코드를 출력할 디렉터리

- strict: 엄격한 검사

- target: TSC가 코드를 컴파일할 자바스크립트 버전
*/

 

 

tsc-watch

tsc-watch는 ts 파일의 변경이 감지되면 자동으로 재시작해주는 라이브러리입니다.

yarn add tsc-watch --dev

 

위 명령어를 통해 tsc-watch를 설치하고 package.json의 scripts의 start를"start": "tsc-watch --onSuccess \" node dist/index.js\" "로 수정합니다.

 

tslint.json 

npm i -g tslint // tslint package module 설치

tslint --init

보통 프로젝트는 TSLint 설정(탭을 사용할지 공백을 사용할지 등을 결정하는 코딩 스타일 규약)을 정의하는 파일 tslint.json 파일도 포함합니다.

 

rules에 지정 가능한 모든 규칙은 TSLint 문서에서 확인할 수 있습니다.

자신만의 규칙을 추가하거나 React용 TSLint처럼 미리 정해진 규칙을 추가로 설치할 수 있습니다.

우선은 다음과 같이 설정하겠습니다.

{
    "defaultSeverity": "error",
    "extends": [
        "tslint:recommended"
    ],
    "rules": {
        "semicolon": false,
        "trailing-comma": false,
        "no-console": false
    },
    "rulesDirectory": []
}

 

 

 

시작하기

루트 폴더에 src 폴더와 dist 폴더를 만들고 src 폴더 밑에 index.ts 파일을 만들겠습니다.

index.ts 파일에 타입스크립트를 입력하고 yarn start를 통해 tsc-watch를 실행시켜줍니다. 

 

 


 

타입스크립트 알아보기

먼저 타입스크립트로 정의할 수 있는 여러가지 타입을 알아보도록 하겠습니다.

// [ 숫자 배열 ] 
const values: number[] = [1, 2, 3];
const values2: Array<number> = [1, 2, 3];



// [ 튜플 타입 ]
const data: [string, number] = ["message", 10];



/*
[ null과 undefined 타입 ]
자바스크립트에서 값으로 존재하는 null과 undefined는 
타입스크립트에서 각각 타입으로 존재한다.
주로 다른 타입과 함께 유니온 타입으로 정의할 때 많이 사용된다.
*/
let v1: undefined = undefined;
let v2: null = null;
let v3: number | undefined = 123;



/*
[ void와 never 타입 ]
- 아무 값도 반환하지 않는 함수의 반환 타입은 void 타입으로 정의.
- 항상 예외가 발생해서 비정상적으로 종료되거나 무한 루프 함수의 
반환 타입은 never 타입으로 정의
*/
function f(): void {
    console.log("hi");
}

function f1(): never {
    throw new Error("some error!");
}

function f2(): never {
    while(true) {
        // ...
    }
}



/*
[ object 타입 ]
object 타입을 지정했을 때 객체의 속성에 대한 정보가 없기 때문에
특정 속성값에 접근하면 타입 에러가 발생한다.
속성 정보를 포함하기 위해서는 interface를 이용해야 한다.
*/
let v: object = {
    name: "Mike"
};
// 타입에러: console.log(v.name); 




/*
[ 교차 타입(&)과 유니온 타입(|) ]
*/
let v4: (1 | 3 | 5) & (3 | 5 | 7);
v4 = 3;     // v4에는 3과 5만 할당 가능




/*
[ type 키워드로 타입 별칭 ]
number | string 타입에 Width라는 별칭을 부여한다.
*/
type Width = number | string;
let width: Width;
width = 100;
width = "100px";



/*
[ 열거형 타입 enum ]
- 열거형 타입의 각 원소는 값으로 사용될 수도, 타입으로 사용될 수도 있다.

- 열거형 타입에 값을 할당하지 않으면 자동으로 0을 할당하고
다음 원소에는 +1만큼 증가한 값이 할당된다.

- 열거형 타입의 값으로 숫자를 할당하면 양방향으로 매핑된다.
단 문자열을 할당했을 때는 일반 객체처럼 단방향으로 매핑된다.

ex) 값이 숫자인 경우 
const fruit = {
    Apple: 0,
    0: "Apple",
    // ...
}

*/
enum Fruit {
    Apple,          // 0
    Banana = 5, 
    Orange,         // 6
    Strawberry = "delicious"
}
const v5: Fruit = Fruit.Apple;
const v6: Fruit.Apple | Fruit.Banana = Fruit.Banana;
console.log(Fruit[5]);              // Banana
console.log(Fruit["Banana"]);       // 5


/*
[ 열거형 타입을 위한 유틸리티 함수 ]
열거형 타입을 자주 사용한다면 몇가지 유틸리티 함수를 만들어서 사용하는 것이 좋다.
다음은 특정 열거형 타입에서 원소의 개수를 반환하는 함수이다.
*/
function getEnumLength(enumObject: any) {
    const keys = Object.keys(enumObject);
    // enum의 값이 숫자이면 두개씩 들어가므로 문자열만 계산한다.
    return keys.reduce((acc, key) => 
        (typeof enumObject[key] === "string" ? acc + 1 : acc), 0)
}

// 다음은 입력된 값이 열거형 타입에 존재하는 값인지 검사하는 함수이다.
function isValidEnumValue(enumObject: any, value: number | string) {
    if (typeof value === "number") {
        return !!enumObject[value];
    } else {
        return (
            Object.keys(enumObject)
                .filter(key => isNaN(Number(key)))
                .some(key => enumObject[key] === value)
        );
    }
}

// 위에서 작성한 함수를 사용해보자
enum Fruit {
    Apple,
    Banana,
    Orange,
}

enum Language {
    Korean = "ko",
    English = "en",
    Japanese = "jp",
}

console.log(getEnumLength(Fruit), getEnumLength(Language));     // 3 3
console.log(`1 in Fruit: ${isValidEnumValue(Fruit, 1)}`);       // true
console.log(`5 in Fruit: ${isValidEnumValue(Fruit, 5)}`);       // false
console.log(`ko in Language: ${isValidEnumValue(Language, "ko")}`); // true


/*
[ 상수형 열거 타입 ]
열거형 타입은 컴파일 이후에도 남아있기 때문에 번들 파일의 크기가 불필요하게 커질 수 있다.
상수 열거형 타입을 사용하면 컴파일 결과에 열거형 타입의 객체를 남겨놓지 않을 수 있다. 
*/
const enum Fruit2 {
    Apple,
    Banana,
}
const fruit: Fruit2 = Fruit2.Apple;


const enum Language2 {
    Korean = "ko",
    English = "en",
    Japanese = "jp",
}
const lang: Language2 = Language2.Korean;

/*
[ 위 상수 열거형 타입이 컴파일된 결과 ]
const fruit = 0;
const lang = "ko";

열거형 타입의 객체를 생성하는 코드가 보이지 않는다. 열거형 타입이 사용된 코드는 
원소의 값으로 대체되므로 const enum을 사용하면 코드가 상당히 간소화된다.
하지만 열거형 타입을 상수로 정의하면 열거형 타입의 객체를 사용할 수 없다. 
*/
// console.log(getEnumLength(Fruit2));     // error

 

 

기본적인 타입들을 알아보았습니다. 

지금부터는 함수 타입을 정의하기 위해 함수의 매개변수와 반환 타입을 어떻게 정의하는지에 대해 자세히 알아보도록 하겠습니다.

/*
[ 함수 타입 ]
함수의 타입을 정의하기 위해서는 매개변수의 타입과 반환 타입이 필요하다.
*/
function getInfoText(name: string, age: number): string {
    const nameText = name.substring(0, 10);
    const ageText = age >= 35 ? "senior" : "junior";
    return `name: ${nameText}, age: ${ageText}`;
}
const v7 = getInfoText("Mike", 23);

/*
[ 변수에 함수 저장하기 ]
자바스크립트에서 함수는 일급이므로 함수를 변수에 저장할 수 있습니다.
함수를 저장할 변수의 타입은 다음과 같이 화살표 기호를 이용합니다.
*/
const getInfoText2: (name: string, age: number) => string = getInfoText;
const v8 = getInfoText2("Seungsu", 25);


/*
[ 선택 매개변수 ]
반드시 입력하지 않아도 되는 매개변수는 물음표 기호를 입력합니다.
*/
function getInfoText3(name: string, age: number, language?: string): string {
    const nameText = name.substring(0, 10);
    const ageText = age >= 35 ? "senior" : "junior";
    const languageText = language ? language.substring(0, 10) : "";
    return `name: ${nameText}, age: ${ageText}, language: ${languageText}`;
}

/*
[ 매개변수 기본값 정의 ]
타입을 입력하지 않아도 매개변수의 기본값을 정의할 수 있다.
기본값이 문자열이기 때문에 매개변수 language 타입도 문자열로 지정된다.
*/
function getInfoText4(name: string, age: number = 15, language = "kor"): string {
    // ...
    return "";
}


/*
[ 나머지 매개변수 ]
나머지 매개변수는 배열로 정의한다.
*/
function getInfoText5(name: string, ...rest: string[]): string {
    // ...
    return "";
}


/*
[ 함수의 this 타입 ]
함수의 this 타입을 정의하려면 첫번째 매개변수 위치에서 정의한다.
*/
function getParams(this: string, index: number): Array<string> {
    const params = this.split(",");
    // ...
    return params;
}

 

 

 

인터페이스

자바에서 인터페이스는 클래스를 구현하기 전에 필요한 메서드들을 정의하는 곳이었습니다.

타입스크립트에서는 인터페이스를 통해 좀 더 다양한 것들을 정의하는 데 사용됩니다.

인터페이스로 타입을 정의하는 방법에 대해 알아보도록 하겠습니다.

 

/*
[ 인터페이스로 객체 타입 정의하기 ]
- Person 인터페이스를 정의하고 객체 내부에 존재하는 각 속성의 타입을 정의한다.
- 선택 속성은 물음표 기호를 사용해서 나타낼 수 있다.
- 읽기 전용 속성은 readonly 키워드를 사용한다.
*/
interface Person {
    readonly name: string;
    age: number;
    hobby?: Array<string>
}

const p1: Person = {name: "Mike", age: 25};
// const p1.name = "John";      // error



/*
[ 인덱스 타입 ]
- 인터페이스에서 속성 이름을 구체적으로 정의하지 않고 값의 타입만 정의  
*/
interface Person2 {
    readonly name: string;
    age: number;
    [key: string]: number | string;
}

const p2: Person2 = {
    name: "Miky",
    age: 25,
    birthday: "1997-01-01",
}



/*
[ 인터페이스로 함수와 클래스 구현 ]
클래스 내부에서는 인터페이스에서 정의한 세 속성을 꼭 구현해야한다.
*/
interface GetInfoText {
    (name: string, age: number): string;
}

const getInfoText: GetInfoText = function(name, age) {
    // ...
    return "";
}

interface MyPerson {
    name: string;
    age: number;
    isYoungerThan(age: number): boolean;
}

class SomePerson implements MyPerson {
    name: string;
    age: number;
    constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
    }
    
    isYoungerThan(age: number) {
        return this.age < age;
    }
}



/*
[ 인터페이스 확장하기 ]
- extends 키워드를 활용해 인터페이스를 확장할 수 있다.
- 교차 타입을 이용해서 인터페이스를 합칠 수 있다.
*/
interface Person3 {
    name: string;
    age: number;
}

interface Korean {
    isLiveInSeoul: boolean;
}

interface Programmer extends Person3, Korean {
    programLanguage: string;
}




interface Person4 {
    name: string;
    age: number;
}

interface Product {
    name: string;
    price: number;
}

type PP = Person4 & Product;
type PP2 = Person4 | Product;

// Person4와 Product의 모든 속성을 포함해야 만족
const pp: PP = {
    name: "a",
    age: 23,
    price: 1000
}

// Person4와 Product 중 한가지를 완전히 포함해야 만족
const pp2: PP2 = {
    name: "123",
    age: 25,
}