본 글은 Typescript Programming을 요약한 글입니다.
자세한 내요은 본 책을 읽으시길 바랍니다.

 

 12장에서는 타입스크립트 응용 프로그램을 빌드하고 제품화하는 방법을 살펴본다.

  • 타입스크립트 응용 프로그램을 빌드하는 데 필요한 준비물
  • 서버에서 타입스크립트 응용 프로그램을 빌드하고 실행하기
  • 브라우저에서 타입스크립트 응용 프로그램을 빌드하고 실행하기
  • 타입스크립트를 빌드하고 NPM으로 발행하기

12.1 타입스크립트 프로젝트 빌드하기

 12.1.1 프로젝트 레이아웃

이 책에서는 타입스크립트 소스 코드를 최상위의 src/ 디렉터리에 저장하고, 컴파일한 결과 역시 최상위 dist/ 디렉터리에 저장할 것을 권장한다. 소스 코드와 생성된 코드를 두 개의 최상위 디렉터리에 분리할 수 있어서 다른 도구들과 통합하기가 편해진다. 또한 빌드 과정에서 만들어지는 부산물을 버전 관리 대상에서 제외하기 쉽다는 장점도 있다.

12.1.2 부산물

타입 파일 확장자 tsconfig.json 플래그 기본적으로 생성?
자바스크립트 .js {"emitDeclarationOnly": false} O
소스맵 .js.msp {"sourceMap": true} X
타입 선언 .d.ts {"declaration": true} X
선언 맵 .d.ts.map {"declarationMap": true} X
  1. 자바스크립트 파일 : TSC는 타입스크립트를 자바스크립트로 변환한다.
  2. 소스맵 : 생성된 자바스크립트 코드를 원래 타입스크립트 파일의 행과 열로 연결하는 데 필요한 특별 파일이다. 디버깅에 도움을 주고 자바스크립트 예외의 스택 추적값에서 가리키는 행과 열을 타입스크립트 파일에 매핑해준다.
  3. 타입선언 : 생성된 타입을 다른 타입스크립트 프로젝트에서 이용할 수 있도록 해준다.
  4. 선언 맵 : 타입스크립트 프로젝의 컴파일 시간을 단축하는 데 사용된다.

12.1.3 컴파일 대상 조정

자바스크립트는 매년 규격 명세가 새롭게 릴리스되면서 빠르게 진화할 뿐만 아니라 프로그래머 입장에서는 자신이 구현한 프로그램이 어떤 자바스크립트 번전을 지원하는 플랫폼에서 실행될지 보장할 수 없다. 더 나아가 많은 자바스크립트 프로그램은 단일 형태, 서버와 클라이언트 모두에서 실행할 수 있다.

  • 백엔드 자바스크립트 프로그램을 자신이 제어할 수 있는 서버에서 실행할때는 정확한 어떤 버전으로 실행할지 결정할 수 있다.
  • 백엔드 자바스크립트 프로그램을 오픈 소스로 릴리스한다면 소비자의 플랫폼에서 어떤 버전으로 실행할지 알 수 없다.
  • 자바스크립트를 브라우저에서 실행할 때는 사람들이 어떤 브라우저를 사용할지 알 수 없다.
  • 단일 구조의 자바스크립트 라이브러리를 릴리스한다면 가능한 한 낮은 버전의 Nodejs와 자바스크립트 엔진 및 버전을 동시에 지원해야한다.

모든 환경과 버전을 지원하는 것은 아니지만 코드는 가능하면 최신 버전으로 작성하는 것이 좋다. 이렇게 해도 구 버전 플랫폼에서 동작하게 할 수 있는데 두 가지 방법이 존재한다.

  1. 트랜스파일 : 최신 버전의 자바스크립트를 대상 플랫폼에서 지원하는 가장 낮은 자바스크립트 버전으로 변환한다.
  2. 폴리필 : 실행하려는 자바스크립트 런타임이 포함하지 않는 최신 기능을 폴리필로 제공한다.

TSC는 트랜스파일하는 기능을 기본으로 지원하지만 폴리필은 자동으로 해주지 않는다. TSC에서 대상 환경에 관한 정보를 설정하는 옵션은 세 가지다.

  • Target : 트랜스파일하려는 자바스크립트 버전을 설정한다.
  • module : 대상 모듈 시스템을 설정한다.
  • lib : 타입스크립트 대상 환경에서 어떤 자바스크립트 기능을 지원하는지 알려준다.

응용 프로그램을 실행할 환경의 자바스크립트 버전을 target에 설정하고, 어떤 기능을 쓸지는 lib에 설정한다. module 값은 대상 환경이 NodeJS냐 브라우저냐에 따라 달라지며, 브라우저 환경에서는 어떤 모듈 로더를 쓰는지를 고려해 정해야 한다.

 

target

TSC의 내장 트랜스파일러는 대부분의 자바스크립트 기능을 예전 자바스크립트 버전으로 변환할 수 있다. 트랜스파일 대상을 설정하려면 tsconfig.json 파일을 열어서 target 필드에 원하는 값을 채워 넣는다.

 

lib

 트랜스파일할 때 한 가지 유의사항이 있다. 대상 환경에서 새로운 기능을 지원하지 않으면 폴리필로 직접 제공해야 한다는 사실이다. 이런 폴리필을 우리가 직접 구현할 필요는 없다. core-js 같은 폴리필 라이브러리에서 필요한 기능을 설치하거나, @babel/polyfill을 설치한 . 후 바벨을 이용해 컴파일하면 타입스크립트가 타입을 확인하면서 필요한 폴리필을 자동으로 설치해준다.

 폴리필을 추가했으면 tsconfig.json의 lib 필드를 수정해서 해당 기능이 반드시 지원됨을 TSC에 알리도록 한다. 브라우저에서 실행할 때는 window, document 등 자바스크립트를 브라우저에서 실행할 때 필요한 API들을 사용할 수 있도록 DOM 타입 선언도 활성화해야 한다.

{
	"compilerOptions": {
    	"lib": [
        	"es2015",
            "es2016.array.includes",
            "dom"
        ]
    }
}

12.1.4 소스 맵 활성화

소스 맵은 트랜스파일된 코드를 원본 코드와 이어주는 정보를 제공한다. 대부분의 개발 도구, 에러 리포팅/로깅 프레임워크, 빌드 도구는 소스 맵의 존재를 이미 알고 있다. 보통 빌드 파이프라인은 처음 코드와는 상당히 다른 형태의 코드들을 중간중간 생성하기 때문에 파이프라인 곳곳에서 소스 맵을 활용하여 최종 자바스크립트의 디버깅을 휠씬 수월하게 처리해준다.

 개발 환경에서는 물론, 실제 제품에서도 브라우저와 서버 환경에 소스 맵을 함께 배포하면 좋다. 단점이 있다면 하나, 보안이 요구되는 상황이라면 고객용 브라우저 환경에는 소스 맵을 포함시키지 않는 편이 좋다.

12.1.5 프로젝트 참조

 응용 프로그램의 크기가 커지면 TSC가 타입을 확인하고 코드를 컴파일하는 데 더 오래 걸린다. 이 문제의 해법으로, TSC는 점진적 컴파일과 함께 프로젝트 참조라는 기능을 제공하여 컴파일 시간을 획기적으로 줄인다. 프로젝트 참조는 다음처럼 사용한다.

 1. 타입스크립트 프로젝트를 여러 프로젝트로 분리한다. 함께 수정될 가능성이 큰 코드들을 같은 디렉터리에 저장하는 방식으로 쪼갠다.

 2. 각 프로젝트 디렉터리에 최소한 다음 정보를 포함하는 tsconfig.json을 만든다.

{
	"compilerOptions": {
    	"composite": true,
        "declearation": true,
        "declarationMap": true,
        "rootDir": "."
    },
    "include": [
    	"./**/*.ts"
    ],
    "references": [
    	{
        	"path": "../myReferencedProject",
            "prepend": true
        }
    ],
}
  • composite: TSC에 이 디렉터리는 큰 타입스크립트 프로젝트의 서브 프로젝트임을 알려준다.
  • declaration: TSC에 이 프로젝트의 .d.ts 선언 파일을 생성하라고 지시한다. 각각의 프로젝트는 다른 프로젝트의 선언 파일과 생성된 자바스크립트 파일들에는 접근할 수 있지만, 원본 타입스크립트 파일에는 접근하지 못한다. 이렇게 하여 TSC가 타입을 다시 검사하거나 다시 컴파일해야하는 코드를 선택하는 기준이 만들어진다. 프로젝트 참조가 큰 프로젝트의 빌드 효율을 높여주는 핵심 원리다.
  • declarationMap: TSC에 생성된 타입 선언의 소스 맵을 빌드하라고 지시한다.
  • reference: 이 서브 프로젝트가 의존하는 다른 서브 프로젝트들의 목록이다.
    • 각 참조의 path는 tsconfig.json 파일이 담긴 디렉터리를 가리키거나, TSC 설정파일을 직접 가리켜야 한다.
    • prepend는 참조하는 서브 프로젝트에서 생성한 자바스크립트와 소스 맵을 이 서브 프로젝트에서 생성한 소스와 맵에 이어 붙인다.
  • rootDir: 이 서브 프로젝트가 루트 프로젝트(.)에 상대적으로 컴파일되어야 함을 명시한다.

 3. 아직 다른 서브 프로젝트에서 참조하지 않은 모든 서브 프로젝트를 참조하는 루트 tsconfig.json을 만든다.

{
	"files": [],
    "references": [
    	{
        	"path": "./myProject"
        },
        {
        	"path": "./mySecondProject"
        }
    ]
}

 

 4. 프로젝트를 컴파일할 때 프로젝트 참조를 활용하도록 bulid 플래그를 지정한다.

tsc --bulid # 또는 줄여서 tsc -b

12.1.6 에러 모니터링

타입스크립트는 컴파일 타임의 에러만 경고하므로 사용자가 런타임에 겪을 수 있는 에러를 컴파일 타임에 방지할 수 방법을 찾아야한다. Sentry,Bugsnag 같은 에러 모니터링 도구를 이용하면 런타임 예외를 보고하고 분석하는 데 도움이 된다.

12.2 서버에서 타입스크립트 실행

타입스크립트 코드를 NodeJS 환경에서 실행하려면 tsconfig.json의 module 플래그를 commonjs로 설정하고, 코드를 ES2015 자바스크립트로 컴파일한다. 그러면 ES2015의 import를 require로, export를 module.exports로 변환하여 NodeJS에서 추가 번들 없이 실행할 수 있도록 컴파일해준다. 소스맵도 NodeJS 프로세스에 제공해야 한다.

12.3 브라우저에서 타입스크립트 실행

브라우저에서 타입스크립트를 실행하려면 서버에서 보다 해야할 과정이 많다.

 

 1. 컴파일 하려는 모듈을 선택한다. 라이브러리를 발행할 때는 umd를 사용해 다양한 모듈 번들러와의 호환성을 극대화해야한다. 발행 계획이 없다면 사용하는 모듈 번들러에 맞는 포맷으로 컴파일하면 되니 해당 번들러의 문서를 확인해 본다.

 

 2. 모든 타입스크립트 파일으 한 개의 자바스크립트 파일이나 자바스크립트 파일 집합으로 컴파일하도록 빌드 파이프라인을 설정한다. TSC는 빌드 플러그인이나 웹팩이 제공하는 정도로 영리한 코드 분할을 제공하지 않으므로 자체 기능으로는 부족하다 사실을 느낄 것이다. 그렇기에 처음부터 강력한 빌드 도구를 사용하느 것이 바람직하다.

  • 빌드 도구가 프로젝트 의존성 그래프를 더 정확하게 분석할 수 있도록, 코드를 모듈로 유지하고 코드에서 임의의 의존성을 피한다.
  • 동적 임포트를 이용하여 게으르게 로딩하면 첫 페이지 렌더링 속도를 높일 수 있다.
  • 자동 코드 분할 기능을 사용하면 페이지 로딩이 불필요하게 느려지지 않는다.
  • 페이지 로딩 시간을 측정하는 수단을 마련한다.
  • 제품 빌드를 가능한한 개발 빌드와 같은 형태로 유지한다.
  • 빠진 브라우저 기능을 폴리필로 제공하는 대책을 마련한다.
    • 모든 번들에 폴리필로 제공하는 표준 집합을 마련하거나
    • 브라우저가 지원하는 기능이 무엇이냐에 따라 동적으로 필요한 폴리필을 마련할 수 있다.

12.4 타입스크립트 코드를 NPM으로 발행하기

타입스크립트를 다른 누군가가 사용하도록 컴파일 할때는 다음 규칙들을 따르는 것이 좋다.

  • 자신의 코드를 쉽게 디버깅할 수 있도록 소스 맵을 생성한다.
  • 다른 사람이 우리의 코드르 쉽게 빌드하고 실행할 수 있도록 ES5로 컴파일한다.
  • 어떤 모듈 타입으로 컴파일할지 주의 깊게 결정한다.
  • 다른 타입스크립트 사용자가 우리 코드의 타입을 얻을 수 있도록 타입 선언을 생성한다.

다음과 같은 과정을 거친다.

  1. 먼저 tsc로 타입스크립트를 자바스크립트로 컴파일하고 대응하는 타입선언을 생성한다.
  2. 이어서 NPM에 발행하지 않을 타입스크립트 코드 목록을 .npmignore 파일에 기재하여 패키지가 너무 커지지 않도록한다. 그리고 .gitignore 파일에는 부산물을 제외하게끔 설정해서 깃 저장소가 불필요한 파일로 오염되는 일을 방지한다.
  3. 마지막으로 프로젝트의 package.json에 "types" 필드를 추가해서 타입 선언이 제공될 것임을 알려준다. 스크립트도 추가해서 패키지의 자바스크립트, 타입선언, 소스 맵이 항상 원본 타입스크립트와 같은 최신 버전이 되도록 만든다.

12.5 세 슬래시 지시어

이 지시어는 특별한 포맷의 타입스크립트 주석으로, TSC에 명령을 하달한다. types는 타입 전용 전체 모듈 임포트를 생략할 때 사용하고, amd-module은 생성된 AMD 모듈의 이름을 정할 때 사용한다.

12.5.1 types 지시어

 모듈에서 무언가를 임포트한 코드를 자바스크립트로 컴파일 할 때 타입스크립트가 항상 import나 require 호출을 생성하는 것은 아니다. export한 대상들이 모듈에서 오직 타입 위치에서만 쓰인다면, 타입스크립트는 해당 import 문에 해당하는 자바스크립트 코드를 전혀 생성하지 않는다. 임포트된 대상들이 타입 수준에서만 존재한다고 생각하기 때문이다. 이 기능을 임포트 생략이라고 부른다.

12.5.2 amd-module 지시어

 타입스크립트 코드를 AMD 모듈 포맷으로 컴파일할 때 타입스크립트는 기본값으로 익명 AMD 모듈을 생성한다. 이때 코드에서 amd-module 세 슬래시 지시어를 추가해주면 AMD 모듈에 이름을 지어줄 수 있다.

// <amd-module name="LogService" /> 1
export let LogService = { // 2
	log() {
    	// ...
    }
}
  1. amd-module 지시어에 name 속성을 설정했다.
  2. 나머지 코드는 그대로이다.

TSC로 AMD 모듈 포맷으로 컴파일하면 다음의 자바스크립트 코드가 생성된다.

// <amd-module name="LogService" /> 1
definde('LogeService', ['require','exports'], function(require, exports){ // LogeService라는 이름이 생겼다
	// .... 
})

AMD로 모듈로 컴파일할 떄는 코드를 번들링하거나 디버깅하기 쉽도록 amd-module 지시어를 사용하자.

본 글은 Typescript Programming을 요약한 글입니다.
자세한 내요은 본 책을 읽으시길 바랍니다.

 

 이번 11장에서는 타입을 쓰지 않는 언어, 즉 서드 파티 자바스크립트 라이브러리를 사용한다거나 빠른 패치를 위해 타입 안전성을 포기하는 등의 실전 상황에서 타입스크립트를 접목하는 방법을 알아보려한다.

  1. 타입 선언 사용하기
  2. 자바스크립트에서 타입스크립트로 점진적으로 마이그레이션하기
  3. 서드 파티 자바스크립트와 타입스크립트 사용하기

11.1 타입선언

타입 선언은 d.ts 확장자를 가진 파일이다. JSDoc 어노테이션과 더불어, 타입 선언은 타입이 없는 자바스크립트 코드에 타입스크립트 타입을 부여할 수 있는 수단이다. 타입 선언은 일반 자바스크립트와 비슷하지만 몇 가지 차이점이 존재한다.

  • 타입만 포함할 수 있고 값은 포함할 수 없다.
  • 값을 정의할 수 없지만, declare라는 특별 키워드를 사용해 다른 어딘가에 값이 있다는 사실을 선언할 수 있다.
  • 소비자가 볼 수 있는 대상에만 타입을 선언할 수 있다. 노출되지 않은 타입이나 함수 안에 선언된 지역 변수의 타입은 포함할 수 없다.
import {Subscriber} from './Subscriber'
import {Subscription} from './Subscription'
import {PartialObserver, Subscribable, TeardownLogic} from './types'

export declare class Observable<T> implements Subscribable<T> { // 1
	// ...
    // ...
    ):Subscription // 2
}
  1. 타입 선언에선 클래스를 직접 정의할 수는 없지만, 그 대신 .d.ts 파일에 대응하는 자바스크립트 파일 안에 정의했음을 선언(declare)할 수 있다.
  2. 타입 선언은 구현을 포함하지 않으므로 subscrive의 오버로드 두 개만 포함하고 구현 시그니처는 포함하지 않는다.

 자세히 보면 Observable.ts에서 구현을 빼면 Observable.d.ts가 된다는 사실을 확인할 수 있다. 라이브러리의 다른 파일에서는 직접 Observable.ts 타입스크립트 파일에 접근하고 필요한 기능을 사용할 수 있으므로 이런 타입 선언이 전혀 필요가 없다. 하지만 타입스크립트 응용 프로그램에서 라이브러리를 사용할 때는 상황이 다르다.

 

타입 선언 파일은 다음처럼 활용된다.

  1. 다른 사용자가 타입스크립트 응용 프로그램에서 우리가 만든 컴파일한 타입스크립트를 사용한다면 그들의 TSC 인스턴스는 우리의 타입스크립트로부터 생성된 자바스크립트 파일에 대응하는 d.ts 파일을 검색한다.
  2. 타입스크립트가 지원하는 코드 편집기는 이 .d.ts 파일들을 읽어 해석한 다음 우리가 코드를 작성할 떄 유용한 타입 힌트를 제공한다.
  3. 불필요한 재컴파일을 막아주어 컴파일 시간을 크게 줄여준다.

타입 선언은 타입스크립트에 "자바스크립트에는 이런 정보가 정의되어 있다"라고 알려주는 수단이다. 때로는 값을 포함하는 일반적인 선언과 구별하기 위해 타입 선언에 앰비언트라는 표현을 쓰기도 한다. 타입 선언은 다음과 같은 상황에서 사용할 수 있다.

  • 자바스크립트 어딘가에 전역 변수가 정의되어 있음을 타입스크립트에 알림
  • 프로젝트 어디에서나 전역으로 이용할 수 있는 타입을 정의하여 임포트 없이 바로 사용하고자 할 때
  • 타입스크립트에 NPM으로 설치한 서드 파티 모듈이 있음을 알릴 때

어떤 목적으로 사용하든 타입 선언은 스크립트 모드의 .ts나 .d.ts 파일 안에 위치해야 한다. 또한 타입 선언 파일에 선언된 최상위 수준 '값'에는 declare 키워드를 사용해야 하지만 최상위 수준 '타입'과 '인터페이스'에서는 사용하지 않아도 된다.

11.1.1 앰비언트 변수 선언

엠비언트 변수 선언은 한 프로젝트의 모든 .ts와 d.ts 파일에서 임포트 없이 사용할 수 있는 전역 변수의 존재를 타입스크립트에 알리는 수단이다.

declare let process: {
	env: {
    	NODE_ENV: 'development'|'production'
    }
}

process = {
	env: {
    	NODE_ENV: 'production'
    }
}

프로그램이 어느 순간 process.env.NODE_ENV를 확인한다고 가정할 때, process라는 전역객체가 있고, 이 객체는 한 개의 env 프로퍼티를 가지고 있으며 env는 NODE_ENV라는 프로퍼티를 갖고 있음을 타입스크립트에 선언하면 에러없이 처리할 수 있다.

11.1.2 앰비언트 타입 선언

엠비언트 타입 선언은 앰비언트 변수 선언과 같은 규칙을 준수한다. 즉, 선언은 스크립트 모드 파일의 .ts나 .d.ts 파일에 저장해야 하며 명시적으로 임포트 하지않아도 프로젝트의 모든 파일에서 전역으로 이용할 수 있다.

 예를 들어, 배열이 아니면 배열로 전환해주는 전역 유틸리티 타입 ToArray<T>가 있다고 가정할 때 같은 프로젝트의 모든 스크립트 모드 파일에서 이 타입을 정의할 수 있다. 최상우 수준의 types.ts 파일에 정의 한다면 다른 파일에서 명시적으로 임포트 하지 않고도 이타입을 사용할 수 있다.

// types.ts
type ToArray<T> = T extends unknown[] ? T : T[]


// 다른 파일
function toArray<T>(a:T): ToArray<T>{
	// ...
}

11.1.3 앰비언트 모듈 선언

자바스크립트 모듈을 사용하면서 그 모듈에서 사용할 일부 타입을 빠르게 선언하고 안전하게 사용하고 싶다면 앰비언트 모듈 선언을 사용하자. 앰비언트 모듈 선언은 평범한 타입 선언을 declare module이라는 특별한 문법으로 감싸는 것이 특징이다.

declare module 'module-name' {
	export type MyType = number
    export type MyDefaultType = { a : string}
    export let myExport : MyType
    // ...
}

 모듈명은 정확한 import 경로에 대응한다. 이 경로를 임포트하면 앰비언트 모듈 선언이 타입스크립트에 무엇을 이용할 수 있는지 알려준다. 중첩된 모듈이 있다면 선언에 import 경로 전체를 포함해야 한다.

import ModuleNmae from 'module-name'
ModuleName.a // string

declare module '@most/core' {
	// 타입 선언
}

모듈 선언은 와일드카드 임포트를 지원하므로 '주어진 패턴과 일치하는 모든 import 경로' 특정한 타입으로 해석하도록 할 수 있다. 와일드 카드를 이용해 import 경로에 패턴을 부여해보자

// 웹팩의 json 로더로 임포트한 JSON 파일의 타입 선언
declare module 'json!*' {
	let value: object
    export default value
}

// 웹팩의 스타일 로더를 임포트한 CSS 파일의 타입 선언
declare module '*.css' {
	let css: CSSRuleList
    export default css
}

// 다른 파일에서 json, css 파일을 로드 할 수 있디.
import a form 'json!myFile'
import b form './widget.css'
a // 객체
b // CSSRuleList

11.2 자바스크립트를 타입스크립트로 천천히 마이그레이션하기

이는 다음의 과정을 거쳐야한다.

  1. TSC를 프로젝트에 추가한다.
  2. 기존 자바스크립트 코드에 타입 확인을 시작한다.
  3. 한 번에 한 파일씩 자바스크립트를 타입스크립트로 마이그레이션한다.
  4. 의존하는 외부 코드용 타입 선언을 설치한다. 
    1. 아직 타입이 없는 외부 코드용 타입의 스텁 만들기
    2. 타입 선언을 만들어서 DefinitelyTyped에 기여하기
  5. 코드베이스에 strict 모드를 적용한다.

11.2.1 TSC 추가

 타입스크립트와 자바스크립트가 함께 사용된 코드베이스에서는 TSC가 타입스크립트뿐 아니라 자바스크립트 파일도 컴파일하도록 설정한다. tsconfig.json을 다음처럼 설정하자.

{
	"compilerOtions":{
    	"allowJS": true
    }
}

 이처럼 설정 하나만 바꾸면 TSC가 자바스크립트 파일도 컴파일한다.

11.2.2.1 자바스크립트에 타입 확인 활성화(선택사항)

TSC가 자바스크립트를 처리하기 시작했으므로 타입 확인 기능을 이용할 수 있다. tsconfig.json을 다음처럼 설정하자.

{
	"compilerOtions":{
    	"allowJS": true
        "checkJS": true
    }
}

이제 타입스크립트가 자바스크립트 파일을 컴파일할 때마다 마치 타입스크립트 코드를 다루듯 최선을 다해 타입을 추론하고 검증한다.

 

타입스크립트로 자바스크립트를 실행하면 타입스크립트 코드를 실할 때보다 더 관대한 추론 알고리즘을 적용한다.

  • 모든 함수 매개변수는 선택사항이다.
  • 함수와 클래스의 프로퍼티 타입은 어떻게 선언했는지가 아니라 어떻게 사용했는지에 의해 결정된다.
  • 객체, 클래스, 함수를 선언한 다음에 추가 프로퍼티를 할당할 수 있다.내부적으로 타입스크립트는 각 클래스와 함수 선언에 대응하는 네임스페이스를 생성하고 모든 객체 리터럴에 인덱스 시그니처를 자동 추가해서 이를 구현한다.

11.2.2.2 JSDoc 어노테이션 추가(선택사항)

 급한 상황에서는 기존의 자바스크립트 파일에 추가한 새 함수에만 타입 어노테이션을 추가하고 싶을 수 있다. 이럴 때는 이 파일을 타입스크립트로 변환하기 전까지는 JSDoc 어노테이션을 새 함수에 적용할 수 있다. 타입스크립트는 JSDoc을 이해하며, 타입 검사기에 타입 정보를 알려주는 용도로 쓰인다.

11.2.3 파일 이름을 .ts로 바꾸기

 한 번에 한 파일만 선택하여 확장자를 .js에서 .ts로 바꾼다. 이 순간 에러가 나타나면서 확인되지 않은 타입에러, 누락된 case 문, null 확인 누락, 잘못된 변수 이름 등을 알려준다. 이런 에러를 해결하는 방법은 두 가지다.

  1. 제대로 처리하기. 관련된 모든 파일에서 더 이상 에러가 발생하지 않도록 차분하게 형태, 필드, 함수의 타입을 올바르게 결정한다
  2. 빠르게 처리하기. 닥치는 대로 자바스크립트의 파일 확장자를 .ts로 바꾸되 tsconfig.json 설정은 느슨하게 유지해서 가능한 한 타입 에러가 적게 발생하도록 설정하자.

11.2.4 엄격하게 만들기

TSC의 엄격성과 관련된 플래그를 한 개씩 설정해가면서 코드를 최대한 안전하게 만들 차례다. 마지막에는 TSC의 자바스크립트 상호 운용 플래그를 꺼서 모든 코드가 엄격한 타입의 타입스크립트가 되도록 강제한다.

{
	"compilerOtions":{
    	"allowJS": false
        "checkJS": false
    }
}

11.3 자바스크립트의 타임 검색

타입스크립트 파일에서 자바스크립트 파일을 임포트할 떄 타입스크립트는 다음 알고리즘을 토대로 자바스크립트 코드에 필요한 타입 선언을 검색한다.

  1. js 파일과 이름이 같은 형제 .d.ts. 파일을 찾는다. 이 파일이 존재함녀 .js 파일의 타입 선언으로 사용한다.
  2. 적절한 d.ts 파일이 없고, 만약 allowJS와 checkJS가 true이면 .js 파일의 타입을 추론한다.
  3. 2에도 해당하지 않으면 전체 모듈을 any로 처리한다.

 

서드 파티 자바스크립트 모듈를 임포트 할 떄는 조금 다른 알고리즘을 사용한다.

  1. 모듈의 지역 타입 선언이 존재한다면 그 선언을 사용한다.
  2. 지역 타입 선언이 존재하지 않는다면 모듈의 package.json을 확인한다. types나 typigns라는 필드가 정의되어 있으며 해당 필드가 가리키는 .d.ts 파일을 모듈의 타입 선언 소스로 사용한다.
  3. 아니면 한 번에 한 단계씩 상위 디렉터리로 이동하면서 node_modules/@types 디렉터리를 찾는다.
  4. 그래도 타입 선언을 찾지 못했다면, 앞서 설명한 지역 타입 찾기 알고리즘을 수행한다.

11.4 서드 파티 자바스크립트 사용

서드 파티 자바스크립트 코드를 프로젝트에 설치할 때 다음처럼 세 가지 상황이 일어날 수 있다.

  1. 코드를 설치할 때 타입 선언이 함께 제공됨
  2. 코드를 설치할 때 타입 선언은 제공되지 않지만 DefinitelyTyped에서 선언을 구할 수 있음
  3. 코드를 설치할 때 타입 선언은 제공되지 않지만 DefinitelyTyped에서도 선언을 구할 수 없음

11.4.1 타입 선언을 포함하는 자바스크립트

설치하기만 하면 곧바로 타입 지워늘 완벽하게 받을 수 있다.

11.4.2  DefinitelyTyped에서 타입 선언을 제공하는 자바스크립트

임포트하는 서드 파티 코드가 자체적으로 타입 선언을 포함하지 않더라도 타입스크립트 커뮤니티를 관리하는 엠비언트 모듈 선언 중앙 저장소인 DefinitelyTyped에서 타입 선언을 제공하기도 한다. 설치한 패키지의 타입 선언이 DefinitelyTyped에 있는지 확인하려면 Typed Search에서 검색해보거나, 곧바로 선언이 설치되는지 시도해보자.

pnpm i lodash --save # lodash 설치
pnpm i @types/lodash --save-dev # lodash의 타입 선언 설치

--save-dev 플래그를 설정하면 설치한 타입 선언을 pak-age.json의 devDependencies 필드에 추가해준다.

11.4.3 DefinitelyTyped에서 타입 선언을 제공하지 않는 자바스크립트

 위 세 가지 상황 중 가장 드문 상황이다. 이를 해결하기 위한 다양한 선택지가 존재한다.

  1. 타입을 사용하지 않는 임포트 윗줄에 @ts-ignore 지시어를 추가해서 해당 임포트를 화이트리스트 처리한다.
  2. 빈 타입 선언 파일을 하나 만들어서 화이트리스트를 처리할 모듈을 적어 놓는다. 이렇게 해서 타입스크립트에 임포트 할 수 있는 모듈이 존재함을 알려주지만 어떤 타입을 포함하는지는 알려주지는 않는다.
    타입을 사용하지 않는 모든 모듈 정보를 한 파일에 둔다는 점에서 첫 번째 방법보다는 낫지만 그 안의 모든 export의 타입은 여전히 any이므로 안전성 면에서는 조금의 차이도 없다.
  3. 엠비언트 모듈 선언을 만든다. tpye.d.ts라는 파일을 만들고 빈 선언을 추가한다. 그리고 타입 선언을 채운다.
  4. 3번째 방법으로 만든 타입 선언을 다른 개발자들도 이용할 수 있도록 NPM에 제공한다.

서드 파티 자바스크립트의 타입 선언은 간단하게 구현할 수 있지만 어떻게 구현하는지는 모듈 유형에 따라 달라진다. 부록 D에서 알아보자

본 글은 Typescript Programming을 요약한 글입니다.
자세한 내요은 본 책을 읽으시길 바랍니다.

 

 프로그램을 구현하면서 다양한 수준으로 캡슐화를 표현할 수 있다. 가장 저수준에서는 함수로 동작을 캡슐화할 수 있고, 객체와 리스트 같은 자료구조로 데이터를 캡슐화할 수 있다. 그런 다음 함수와 데이터를 클래스로 묶거나 네임스페이스로 구분된 유틸리티 형태로 별도의 데이터베이스나 저장소에 보관할 수 있다. 보통은 한 파일에 한 개의 클래스나 한 가지 유틸리티 집합을 담는다. 이런 클래스나 유틸리티가 많아지면 패키지로 묶어서 NPM으로 발행할 수도 있다.

 모듈을 이야기 하려면 세가지 방법이 존재하는데

  1. 컴파일러가 모듈을 해석하는 방법
  2. 빌드 시스템이 모듈을 해석하는 방법
  3. 모듈이 실제로 런타임에 응용 프로그램으로 로드되는 방법

등의 차이를 알아야한다. 이전에는 세 가지 일을 각각으로 처리해야됐지만 CommonJS와 ES2015의 모듈 표준이 나타나 세 프로그램을 연동하기 쉬워졌으며, 이제는 웹팩 같은 강력한 번들러가 내부적으로 알아서 추상화해 실행해주는 단계에 이르렀다. 본 장에서는 첫번째, 타입스크립트가 모듈을 어떻게 해석하고 컴파일하는지 알아본다. 앞서 내용을 들어가기 전 배경지식을 쌓아보자.

10.1 가볍게 살펴보는 자바스크립트 모듈의 역사

 처음(1995)에 자바스크립트는 모듈 시스템을 전혀 지원하지 않았다. 모듈이 없어서 모든 것을 네임스페이스에 정의했고, 이 때문에 응용프로그램을 만들고 확장하기 어려웠다. 

  • 변수명이 금방 고갈되면서 같은 변수명으로 인한 충돌 발생
  • 각 모듈의 API를 명시적으로 노출하지 않는다면 어떤 기능을 사용할 수 있는지, 사용하면 안되는 지 구분 X

 이 문제를 해결하고자, 사람들은 객체를 이용하거나 즉시 실행 함수를 전역 window에 할당해서 응용프로그램의 다른 모듈에서 사용할 수 있도록 하는 식으로 모듈을 흉내냈다.

 

 자바스크립트를 로딩하고 실행하는 동안 브라우저의 UI는 블록되기 때문에 웹 응용 프로그램이 커지고 코드가 늘어날수록 사용자의 브라우저는 점점 느려졌다. 이 문제를 해결하려고 페이지를 로드한 다음 필요한 파일만 동적으로 로드하는 방식으로 개발하기 시작했다. 이후 약 10년 후 Dojo, YUI, LABjs 등에서 첫 페이지가 로딩된 다음 자바스크립트가 게으르고 비동기적으로 로딩하는 모듈 로더를 제공했다. 이는 다음과 같은 세 가지를 의미한다.

  1. 모듈이 잘 캡슐화되어야 한다. 그렇지 않으면 의존성을 확보하는 과정에서 페이지가 망가질 수 있다.
  2. 모듈 간의 의존성이 명시적이어야 한다. 그렇지 않으면 한 모듈에 어떤 모듈이 필요하며 어떤 순서로 로딩해야 하는지 알 수 없기 때문이다.
  3. 모든 모듈은 앱 내에서 고유 식별자를 가져야 한다. 그렇지 않으면 어떤 모듈을 로딩해야 하는지 안정적으로 지정할 수 없다.

 비슷한 시기에 Node.js가 개발되었으며 모듈 시스템을 플랫폼 자체에 추가하였다. 위 세가지 조건을 만족해야하는데 Nodejs는 CommonJS 모듈 표준을 통해 이를 해결했다.

 

 몇 년 지난 후에 Beowserify가 출시 되면서 프론트엔드 엔지니어도 CommonJS를  사용할 수 있게 되었고, 사실상 CommonJS가 모듈 번들링, 임포트, 익스포트 문법의 표준으로 자리 잡았다. 하지만 이에도 몇 가지 문제점이 존재하였다.

  • require 호출은 반드시 동기 방식이어야 한다는 점
  • 모듈 해석 알고리즘이 웹에 적합하지 않은 점
  • 상황에 따라 정적 분석이 불가능했다는 점

이 문제는 ECMAScript 언어의 여섯 번째 개정판인 ES2015에 이르러 깔끔한 문법과 정적 분석이 가능한 새로윤 표준 임포트/익스포트가 소개되면서 해결되었다. 바로 오늘날 자바스크립트와 타입스크립트에서 사용하는 표준이다.

import eamilList from 'emailListModule'
improt emailComposer from 'eamilComposerModule'

export function renderBase(){
 // ...
}

 하지만 이 표준이 제정된 시점에서 표준을 지원하지 않는 자바스크립트 런타임도 있었기에 환경에 맞는 타입으로 컴파일해야 했다. 그러나 타입스크립트의 빌드 시스템 덕분에 소비자 환경에서 이용할 수 있는 환경을 혼합하는 등 다양한 환경에 맞게 모듈을 컴파일할 수 있게 되었다.

10.2 import, export

 타입스크립트 코드에서는 CommonJS, 전역, 네임스페이스로 구분한 모듈 보다는 ES2015의 import와 export를 사용하는 것이 바람직하다.

// a.tx
export function foo(){}
Export function bar(){}

// b.tx
import {foo, bar} from './a'
foo()
export let result = bar()

// c.ts : ES2015 모듈 표준은 default export를 지원한다.
export default function meow(loudness: number) {}

// d.ts
import meow from './c' // {}가 없다는 점
meow(11)

// e.ts : 와일드카드 임포틑 이용해 모듈의 모든 것을 임포트할 수 있다.
import * as a from './a'
a.foo()
a.bar()

// f.ts : 모듈에서 일부(또는 전체)를 다시 익스포트할 수 있따.
export * from '/.a'
export {result} from './b'
export meow from './c'

 타입스크립트를 사용하고 있으므로 값뿐만 아니라 타입과 인터페이스도 익스포트할 수 있다. 타입과 값은 별개의 네임스페이스에 존재하므로 두 가지를 하나의 이름으로 익스포트할 수 있다.

// g.ts
export let x = 3
export type x = {y : string}

// h.ts
import {x} from './g'

let a = x + 1 // x는 값 x를 가리킴
let b: x = {y : z} // x는 타입 x를 가리킴

 10.2.1 동적 임포트

 응용 프로그램이 커지면서 첫 렌더링 시간이 점점 길어진다. 프론트엔드에서는 코드를 분할하여 해결할 수 있다. 자바스크립트 파일을 여러 개 생성하여 나누어 저장하는 방법이다. 여러 조각을 병렬로 로딩할 수 있으므로 용량이 큰 네크워크 요청을 더 수월하게 처리할 수 있다.

 

 또한 코드가 꼭 필요할 때 로딩하는 게으른 로딩으로 조금 더 최적화 할 수 있다. LABjs와 관련 라이브러리는 필요할 때만 코드를 로딩하는 게으른 로딩의 개념을 소개했으며 이를 동적 임포트라는 개념으로 공식화했다.

let locale = await import('locale_us-en')

 개발자는 import를 모듈의 Promise를 반환하는 함수로 이용할 수 있다. import로 문자열로 평가되는 표현식이라면 무엇이든 전달할 수 있지만, 대신 타입 안정성을 잃게 된다. 이를 안전하게 사용하려면 다음 두 방법 중 하나를 이용해야 한다.

  1. 문자열을 변수에 할당하지 않고 import에 문자열 리터럴로 직접 제공한다.
  2. import에 표현식을 전달하고 모듈의 시그니처를 직접 명시했다.

 두 번째 방식을 사용할 때는 주로 모듈을 정적으로 임포트하지만 오직 타입 위치에만 사용한다.

import {locale} from './locales/locale-us'

async function main() {
	let userLocale = await getUserLocale()
    let path = ./ locales / locale-${userLocale}
    let localeUs: typeof locale = await import(path)
}

  위 예에선 locale을 값이 아닌 타입으로만 활용했으므로 타입스크립트는 정적 임포트를 컴파일해버리고 개발자는 타입 안정과 동적으로 계산된 임포트라는 두 마리 토끼를 잡을 수 있다.

10.2.2 CommonJS와 AMD 코드 사용하기

CommonJS나 AMD 표준을 사용하는 자바스크립트 모듈을 이용할 때는 ES2015 모듈을 사용할 때처럼 단순히 이름으로 임포트 할 수 있다. 그러나 디폴트 익스포트와 궁합이 맞지 않으므로 필요하면 와일드카드 임포트를 사용해야 한다.

import * as fs from 'fs'
fs.readFile('some/file.txt')

10.2.3 모듈 모드 vs. 스크립트 모드

 타입스크립트는 타입스크립트 파일을 모듈 모드 또는 스크립트 모드 중 하나로 파싱한다. import나 export를 포함하면 모듈 모드로, 그렇지 않으면 스크립트 모드로 동작한다. 지금까지 거의 모듈모드를 사용했고 앞으로도 대부분 그럴 것이다. 다른 파일의 코드를 가져올 때 import와 import()를 사용하고, 코드를 제공할 때 export를 사용한다. 서드 파티 UMD 모듈을 사용한다면 이들을 먼저 import 해야한다.

 스크립트 모드에서는 최상위 수준으로 선언한 모든 변수는 명시적으로 임포트하지 않아도 같은 프로젝트의 다른 파일들에서 사용할 수 있으며, 서드 파티 UMD 모듈의 전역 익스포트도 먼저 명시적으로 임포트할 필요 없이 바로 사용할 수 있다.

10.3 네임스페이스

 타입스크립트는 코드를 캡슐화할 수 있는 또 다른 수단인 namespace 키워드를 제공한다. 네임스페이스는 파일시스템에서 파일이 어떻게 구성되었는지 같은 자잘구레한 세부사항을 추상화한다. .mine 함수가 schemes/scamas/bitcon/apps 위에 위치한다는 사실을 알 필요없이 Schemes.Scamas.Bitcon.Apps.mine 같은 짧고 간편한 네임스페이스로 접근할 수 있게 해준다. 

 네임스페이스에는 반드시 이름이 있어야 하며 함수, 변수, 타입, 인터페이스, 다른 네임스페이스를 익스포트할 수 있다. 블록 안의 모든 코드는 명시적을 익스포트하지 않는 한 볼수 없고 네임스페이스가 중첩된 구조도 쉽게 만들 수 있다. 인터페이스처럼 네임 스페이스도 합칠 수 있므로 하나의 네임스페이스를 여러 파일로 쪼개 관리할 수 있다. 타입스크립트는 이름이 같은 네임스페이스를 알아서 재귀적으로 합쳐준다.

naemspace Network{
	export namespace HTTP {
    	export function get<T>(url: string): Promise<T>{
        	// ...
        }
    }
    export namespace TCP {
    	listenOn(port: number) : Connection {
        	// ...
        }
    }
    
    export namespace UDP {
    	// ...
    }
}

 네임스페이스 계층이 너무 길어졌다면 짧은 별칭을 지어질 수 있다.

// A.ts
namespace A {
	export namespace B {
    	export namespace C {
        	exprot let d = 3
        }
    }
}

// app.ts
import d = A.B.C.d

let e = d * 3

 

10.3.1 충돌

  같은 이름을 익스포트하면 충돌이 생긴다. 단, 함수 타입을 정제할 때 사용하느 오버로도딘 앰비언트 함수 선언에는 이름 충돌 금지 규칙이 적용되지 않는다.

// HTTP.ts
namespace Network {
	export function request<T>(url: string): T
}

// HTTP2.ts
namespace Network {
	export function request<T>(url: string, priority: number): T
}

// HTTPS.ts
namespace Network {
	export function request<T>(url: string, algo: 'SHA1'|'SHA256'): T
}

10.3.2 컴파일된 출력

 임포트, 익스포틀와 달리 네임스페이스는 tsconfig.json의 module 설정에 영향받지 않으며 항상 전역 변수로 컴파일된다.

// Flowers.ts
namespace Flowers {
	export function give(count: number) {
    	return count + 'flowers'
    }
}

// TSC로 컴파일하면 다음과 같은 자바스크립트 파일이 생긴다.
let Flowers
(function (Flowers) { // 1
	function give (count){
    	return count + 'flowers'
    }
    Flowers.give = give // 2
})(Flowers || (FLowers = {})) // 3
  1. Flowers는 클로저를 만들고 Flowers 모듈에서 명시적으로 익스포트하지 않은 변수가 노출되는 것을 방지하기 위해 즉시 실행 함수 안에 선언했다.
  2. 타입스크립트 Flowers 네임스페이스로 익스포트한 give 함수를 할당한다.
  3. Flowers 네임스페이스가 전역으로 이미 정의되어 있으면 타입스크립트는 Flowers를 확장한다. 그렇지 않으면 새로 네임스페이스를 생성(Flowers={})한 다음 확장한다.
  • 가능하면 네임스페이스보다는 모듈을 사용하자 :
     일반 모듈을 사용해 자바스크립트 표준을 따르고 의존성을 명시적으로 만들자. 명시적 의존성은 가독성, 모듈 분리, 정적 분석 면에서 유리하다. 그래서 코드를 제거하고 컴파일된 코드를 여러 파일로 나눠 성능을 높여야 하는 대규모 프론트엔드 프로젝트에서 아주 유용하다.

10.4 선언합치기

 타입스크립트는 다른 종류의 이름을 합치는 다양한 동작을 제공하며, 이를 통해 온갖 종류의 패턴을 표현할 수 있게 해준다.

무엇으로(to)
  클래스 Enum 함수 타입 별칭 인터페이스 네임스페이스 모듈
무엇을
(from)
X X X X O O X -
클래스 - X X X X O O -
Enum - - O X X X O -
함수 - - - X O O O -
타입 별칭 - - - - X X O -
인터페이스 - - - - - O O -
네임스페이스 - - - - - - O -
모듈 - - - - - - - O

 예를 들어 타입스크립트는 값과 타입 별칭을 같은 영역에 선언할 수 있도록 허용하며, 이름이 어떤 위치에 사용되었는지에 따라 값인지 타입인지 추론한다.

본 글은 Typescript Programming을 요약한 글입니다.
자세한 내요은 본 책을 읽으시길 바랍니다.

 

8.1 자바스크립트의 이벤트 루프

거시적으로 보면 자바스크립트 VM은 다음처럼 동시성을 흉내낸다.

  1. 메인 자바스크립트 스레드는 네이티브 비동기 API를 호철한다.
  2. 네이티브 비동기 API를 호출한 이후에 다시 메인 스레드로 제어가 반환되며 아무 일도 없었던 것처럼 코드를 계속 실행한다.
  3. 비동기 작업이 완료되면 플랫폼은 태스크를 이벤트 큐에 추가한다. 각 스레드가 자신만의 큐를 가지고 있으며 이를 이용해 비동기 연산결과를 메인 스레드로 전달한다.
  4. 태스크에는 호출 자체와 관련한 메타 정보 이룹와 메인 스레드와 연결된 콜백 함수의 참조가 들어있다.
  5. 메인 스레드의 콜 스택이 비면 플랫폼은 이벤트 큐에 남아 있는 태스크가 있는 지 확인한다.
  6. 대기 중인 태스크가 있으면 플랫폼은 그 태스크를 실행한다. 이때 함수 호출이 일어나며 제어는 메인 스레드 함수로 반환된다.
  7. 함수 호출이 끝나고 콜 스택이 다시 비면 플랫폼은 다시 기다리는 태스크가 있는 지 이벤트 큐에서 확인한다.
  8. 콜 스택과 이벤트 큐가 모두 비고, 모든 비동기 네이티브 API 호출이 완료 될 때 까지 이 과정을 반복한다.

8.2 콜백 사용하기

비동기 자바스크립트 프로그램의 기본 단위는 콜백이다. 콜백은 다른 함수에 인수 형태로 전달된다. 동기 프로그램처럼 특정 함수가 고유한 동작을 완료하면 호출자가 건넨 콜백 함수를 호출한다.

fs.readFile(
	'/var/log/apache2/access_log',
    {encoding: 'utf8'},
    {error, data} => {
    	if (error){
        	console.log('error writing!', error)
        }
    }
)

fs.appendFile(
	'/var/log/apache2/access_log',
    'New access log entry',
    error => {
    	if (error){
        	console.log('error writing!', error)
        }
    }
)

 위 호출은 비동기로 일어나기 때문에 API 호출 순서로 파일시스템에서 실행할 동작 순서를 결정할 수 없다는 사실을 알지 못한다면 코드가 어떤 로그를 반환할 지 예측하지 못한다. 또한 콜백 방식은 연달아 수행되는 작업을 콛로 표현하기 어렵다는 문제도 있다. 여러 동작을 연달아 실행할 때 보통 한 동작이 성공했을 때만 다음 동작으로 이어가고, 에러가 발생하면 즉시 빠져나와야 할 때가 많다, 콜백을 이용하면 이런 제어를 수동을 처리해야 한다. 특히 동기 방식의 에러까지 관여 되기 시작하면 연이은 콜백을 올바로 처리하기가 상당히 어려워진다. 서로의 결과에 의존하는 콜백이 여러 개 등장하면 문제가 금방 복잡해지기 때문이다.

8.3 프로미스로 정상 회복하기

 비동기 작업을 추상화하여 서로 조합하거나 연결하는 등의 일을 할 수 있는 길을 열여주는 프로미스 개념을 설명한다.

function readFilePromise(path:string) : Promsise<string>{
	return new Promise((resolve, reject)=> {
    	readFile(path, (error, result) => {
        	if(error) {
            	reject(error)
            } else {
            	resolve(result)
            }
        }
    })
}

 위 resolve의 매개변수 타입은 우리가 어떤 API를 사용하는지에 따라 달라지며 reject의 매개변수 타입은 항상 Error 유형이 된다.

type Executor<T, E extends Error> = {
	resolve: (result : T) => void,
    reject: (error : E) => void
} => void
//...

 Promse만 보고도 Promise가 어떤 타입으로 해석될지를 알고자 하므로, Promise를 제네릭으로 만들고 그 생성자에서 자신의 타입 매개변수들을 Executor 타입에 전달할 것이다.

 이후 API 연쇄에 관해 생각해 본다면 Promise를 통해 연이어 실행하면서 결과를 전달하고 예외를 잡게끔하고 싶은 연산인 then과 catch를 추가한다.

class Promise<T, E extends Error> {
	constructor(f: Executor<T,E>) {}
    then<U, F extends Error>(g: (result: T)=> Promise<U, F>:Promise<U, F>)
    catch<U, F extends Error>(g: (error: T)=> Promise<U, F>:Promise<U, F>)

}

 then은 성공한 Promise 결과를 새 Promise로 매핑하여, catch는 거부시 에러를 새 Promise로 매핑한다.

let : a: () => Promise<string, TypeError> = // ...
let : b: (s : string) => Promise<number, never> = // ...
let : c: ()=> Promise<boolean, RangeError> = // ...

a()
	.then(b)
    .catch(e=>c()) // b는 에러가 아니므로 a가 에러일 때 호출됨
    .then(result => console.info('Done', result))
    .catch(e => console.error('Error', e))

 a가 성공하면 Promise를 b를 매핑하고, 그렇지 않으면 첫 번째 catch구문을 실행하면서 Promise를 c로 매핑한다. c가 성공하면 "Done"을 기록하고 거절되면 다시 마지막 catch를 실행한다. 마치 동기식 동작에 적용되는 try/catch를 비동기 동작에 적용하는 것과 같은 효과를 제공한다. 

 실제 예외를 던져야하는 상황을 처리하려면 then과 catch를 구현할 때 코드를 try/catch로 감싸고 catch구문에서 거절하는 식으로 처리하면된다.

  1. 모든 Promise는 거절될 수 있는 위험이 있으며, 정적으로 이를 확인할 수 없다.
  2. Promise가 거부되었다고 항상 Error인 것은 아니다. 타입스크립트는 어쩔 수 없이 자바스크립트의 동작을 상속받는데 자바스크립트는 throw로 모든 것을 던질 수 있기 때문이다. 따라서 거부된 결과가 Error의 서브타입이라고 간주할 수 없다.

이를 감안하여, 에러 타입을 지정하지 않아도 될게끔 Promise 타입을 조금 느슨하게 풀어준다.

class Promise<T> {
	constructor(f: Executor<T>) {}
    then<U>(g: (result: T)=> Promise<U>:Promise<U>) {
    	// ...
    }
    catch<U>(g: (error: unknown)=> Promise<U>:Promise<U>){
    	// ...
    }
}

8.4 async와 await

 프로미스는 비동기 코드를 다루는 강력한 추상 개념이다. 이 패턴이 유명해지면서 자바스크립트도 async와 await라는 형태로 이를 지원하기 시작하였다. 이 문법을 이용하면 마치 동기 작업을 다루듯이 비동기 작업을 처리할 수 있다.

async function getUser() {
	try{
    	let user = await getUserID(18)
        let location = await getLocation(user)
        console.ingo('got location', user)
    } catch (error) {
    	console.error(error)
    } finally {
    	console.info('done getting location')
    }
}

 프로미스가 필요한 곳에는 언제든 이들을 이용할 수 있으며 연결된 여러 동작을 쉽게 이해할 수 있게 해주고 then을 여러 번 사용할 필요가 없어진다.

8.5 비동기 스트림

 만약 미래의 서로 다른 시점에 이용할 수 있게 될 값이 여러 개라면 어떻게 처리해야 할까? 여러 개의 데이터로 이루어지며, 각각의 데이터를 미래의 어떤 시점에 받게 되는 상황을 설계해야한다. 이런 상황에선 이벤트 방출기나 리액티브 프로그래밍 라이브러리를 이용한다. 이 둘의 관계는 콜백과 프로미스의 관계와 비슷하다. 이벤트는 빠르고 가벼운 반면 리액티브 프로그래밍 라이브러리는 더 강력하며 이벤트 스트림을 조합하고 연결하는 기능을 제공한다.

이벤트 방출기

 방출기는 채널로 이벤트를 방출하고 채널에서 발생하는 이벤트를 리스닝하는 API를 제공한다.

interface Emiiter {
	// 이벤트 방출
    emit(channel: string, value: unknown): void
    // 이벤트가 방출되었을 때 어떤 작업을 수행
    on(channel: strong, f : (value: unknown)=> void): void
}

 대부분의 언어에서는 위와 같은 형태의 이벤트 방출기는 안전하지 않다. value의 타입이 특정 channel에 의존하는데 대부분의 언어에서는 이런 관계를 표현할 수 없기 때문이다. 그러나 타입스크립트는 타입 시스템을 이용해 안전하게 표현할 수 있다.

 emit과 on 메서드의 타입을 가능한 한 안전하게 정의해서 Node-Redis 라이브러리를 더 안전하게 사용할 수 있도록 만들어보자.

type RedisClient = {
	on<E extends Keyof Events>(
    	event: E,
        f: (arg: Events[E]) => void
    ): void
    emit<E extends keyof Events>(
    	event: E,
        arg: Events[E]
    ): void
}

 이벤트 이름과 인수를 하나의 형태로 따로 빼내고, 리스너와 방출기를 생성하는 데 이 형태에 매핑하는 패턴은 실무의 타입스크립트 코드에서 자주 볼수 있다. 간결하고 안전하기 때문이다. 철자를 틀리거나 인수 전달을 빼먹는 등의 실수를 막을 수 있고 코드 편집기가 이벤트와 콜백 매개변수 타입을 제시해주게 되므로 다른 개발자에게 코드가 하는 일을 설명하는 문서화 역할도 제공한다.

8.6 타입 안전 멀티스레딩

CPU를 많이 사용하는 작업에서는 진정한 병렬성, 즉 작업을 여러 개의 스레드로 분리해서 속도를 높이거나 메인 스레드의 부하를 줄여 반응성을 높여야 할 때가 있다.

8.6.1 브라우저에서 웹 워커 활용하기

 웹 워커는 브라우저에서의 멀티스레딩 작업을 폭넓게 지원하는 기능이다. Promise, setTimeout 같은 비동기 API는 동시성을 제공하는 반면 워커는 코드를 다른 CPU 스레드에서 병렬로 실행하도록 해준다.

 웹 워커는 브라우저에서 제공하는 API이므로 설계자들은 안정성을에 주안점을 두었다. 공유 메모리를 사용하는 동시성의 약점이 존재하기에 같은 영역의 메모리를 여러 스레드에서 동시에 읽고 쓰려 하면 비결정성이나 데드룩 등 온갖 병렬성 광련 문제에 직면하게 된다. 

 브라우저 코드는 특히 안전해야하고 브라우저가 크래시되거나 사용자 경험을 악화시키는 요인을 최소화해야 하므로 웹 워커가 메인 스레드나 다른 웹 워커와 통신하는 주된 수단은 메시지 전달이 되어야 한다.

// MainThread.ts
let worker = new Worker('WorkerScript.js')
worker.onmessage= e =>{
	console.log(e.data) // 'Ack: "some data"' 기록
}
wroker.postMessage('some data')

// WorkerScript.ts
onmessage = e => {
	console.log(e.data) // 'some data' 기록
    postMessage(Ack: "${e.data}")
}

  웹 워커에서는 전역적으로 이용할 수 있는 onmessage API로 들어오는 이벤트를 리스닝한다. 반대 반향으로 통신할 떄, 즉 워커에서 메인스레드로 실행이 바뀔 때는 전역으로 이용할 수 있는 postMessage를 이용해 메인 스레드로 메시지르 전달하고, .onmessage 메서드로 들어오는 메시지를 리스닝한다.

 이 API는 이벤트 방출기와 많이 비슷하다. 메시지를 편리하게 전달할 수 있다는 장점이 있지만, 타입 정보가 없다면 메시지로 전송될 수 있는 온갖 종류의 타입을 올바로 처리 했는지 알 수가 없다.

 메시지의 모든 가능한 타입의 유니온을 정의한 다음 switch문에서 Message 타입을 기준으로 분기하는 것으로 command 타입을 구현할 수 있다.

//WorkerScript.ts
type Command = // 1
	| {type: 'sendMessageToThread', data: [ThreadID, Message]} // 2
    | {type: 'createThread', data: [Participants] }
    | {type: 'addUserToThread', data: [ThreadID, UserID]}
    | {type: 'removeUSerFromThread', data: [ThreadID, UserID]}
    
onmessage = e => // 3
	processCommandFromMainThread(e.data)
    
function processCommandFromMainThread( // 4
	command: Command
) {
	switch(command.type){ // 5
    	case 'sendMessageToThread':
        	let [threadID,message] = command.data
            console.log(message)
            // ...
    }
}

 

  1. 메인 스레드가 워커 스레드로 보낼 수 있는 모든 명령의 유니온을 정의
  2. 긴 유니온 타입을 정의할 때는 파이프를 앞에 붙인다.
  3. 타입을 지정하지 않은 메시지를 받은 다음 타입을 지정한 processComan.. API에서 처리하도록 위임
  4. 메인 스레드에 들어오는 모든 메시지를 처리한다. 타입을 지정하지 않은 onmessageAPI를 타입을 지정해 감싸주는 래퍼 역할을 한다.
  5. Command 타입은 차별된 유니온이므로 switch문을 이용하여 메인 스레드가 발송할 수 있는 모든 메시지 타입을 처리한다.

 SafeEmitter를 이용하면 리스닝 계층을 안전하게 구현하는 데 필요한 많은 코드를 극적으로 줄일 수 있다. 워커 쪽에서는 모든 onmessage 호출을 방출기로 위임하여 사용자에게 편리하고 안전한 리스너 API를 제공할 수 있다.

class SafeEmiiter<
	Events extends Record<PropertyKey, unknown[]> // 1
>{
	private emitter = new EventEmitter // 2
    emit<K extends keyof Events>( // 3
    	channel: K,
        ...data : Events[K]
    ) {
    	return this.emitter.emit(channel, ...data)
    }
    on<K extends keyof Events>( // 4
    	channel: K,
        listener: (...data: Events[K]) => void
	) {
    	return this.emitter.on(channel, listener)
    }
}
  1. SafeEmitter는 제너릭 타입 Events를 선언한다. Events는 PropertyKey를 매개변수 목록에 매핑하는 Record다.
  2. SafeEmitter에서 emitter를 비공개 멤버로 선언했다. SafeEmitter를 상속 받지 않는 이유는 emit과 on의 시그니처가  EventEmitter에서 오버로드 한 대응 부분보다 더 제한적인데, 타입스크립트에서 함수의 매개변수는 반변이므로 이 함수들을 오버로드 할 수 없기 때문이다.
  3. emit의 첫 번째 인수는 channel이고 이어서 우리가 Events 타입에 정의한 매개변수 목록에 대응하는 인수들을 받는다.
  4. 비슷하게 on은 channel과 listtener를 받는다. listener가 받는 인수의 수는 가변적인데, 바로 우리가 Events 타입에 정의한 매개변수 목록에 대응하는 값들이다.

타입 안전 프로토콜

 두 스레드 사이에 메시지를 주고받는 기능에서 확장하여 특정 명령이 특정한 한 가지 이벤트만 받도록 제한하라면 어떻게 해야 할까?

type Protocol= { // 1
	[commad: string] : {
    	in: unknown[]
        out: unknwon
    }
}

function createProtocol<P extends Protocol>(script: string){ // 2
	return <K extends keyof P>(command: K) => // 3
    	(...args: P[K]['in']) => // 4
        	new Promise<P[K]['in']>((resolve, reject)=>{ // 5
            	let worker = new Worker(script)
                worker.onerror = reject
                worker.onmessage = event => resolve(event.data.data)
                worker.postMessage({ command, args})
            })
}
  1. MatrixProtocal에 한정되지 않는 범용 Protocol 타입을 지정한다.
  2. crateProtocol을 호출할 때는 워커 스크립트의 파일 경로와 구체적인 Protocol을 인수를 전달한다.
  3. createProtocol은 command를 인수로 받는 익명 함수를 반환한다. 이 command는 2에서 설정한 Protocol의 키다
  4. 3에서 전달한 command의 in 타입에 따라 이 익명함수를 호출한다.
  5. 구체적인 프로토콜에서 정의한 대로 command에서 지정한 out 타입의 Promise를 반환한다. 타입 매개변수가 Promise라고 명시했는데 하지 않았으면 기본값으로 {}가 사용된다.

프로토콜에서 사용할 수 있는 모든 명령은 한 곳(MatrixProtocol)에 정의되어 있으며 핵심 로직(createProtocol)은 구체적인 프로토콜 구현(runWithMatrixProtocol)과는 별개로 존재한다. 같은 기기 또는 네크워크로 연결된 다른 컴퓨터에 존재하는 두 프로세스 간에 통신을 해야하는 상황에서 타입 안전 프로토콜은 안전한 통산을 보장하는 훌륭한 도구다.

본 글은 Typescript Programming을 요약한 글입니다.
자세한 내요은 본 책을 읽으시길 바랍니다.

 

7장에서는 타입스크립트에서 에러를 표현하고 처리하는 가장 일반적인 패턴 네가지를 소개한다.

  • null 반환
  • 예외 던지기
  • 예외 반환
  • Option 타입

7.1 null 반환

function parse(birthday: string) : Data | null {
	let date = new DAte(birthday)
    if(!isValid(date)){
    	return null
    }
    return date
}

 타입 안전성을 유지하면서 에러를 처리하는 가장 간단한 방법은 null을 반환하는 것이다. 하지만 parse에서 발생하는 에러를 이 방식으로 처리하면 문제가 생긴 원인을 알수가 없다. 또한 조합이 어려워진다는 문제점도 존재한다. 모든 연산에서 null을 확인해야하므로 연산을 중첩하거나 연결할 때 코드가 지저분해진다.

7.2 예외 던지기

문제가 발생하면 null 반환 대신 예외를 던지자. 그러면 어떤 문제냐에 따라 대처가 가능할 수 있고, 디벙깅에 도움되는 메타데이터도 얻을 수 있다.

function parse(birthday: string) : Data {
	let date = new DAte(birthday)
    if(!isValid(date)){
    	throw new RangeError('Enter a date in the form YYYY/MM/DD')
    }
    return date
}

 나주에 다른 개발자가 parse나 ask에서 또다른 형태의 RangeError를 던질 수 있게 하려면 에러를 서브클래싱하여 더 구체적으로 표현하면 된다.

// 커스텀 에러 타입
class InvaliDateFormatError extends RangeError {}
class DateIsInTheFuterError extends RangeError {}

function parse(birthday: string) : Data {
	let date = new DAte(birthday)
    if(!isValid(date)){
    	throw new InvaliDateFormatError('Enter a date in the form YYYY/MM/DD')
    }
    if (date.getTime() > Date.now()){
    	throw new DateIsInTheFuterError('Are you a timelord?')
    }
    return date
}

 커스텀 에러를 이용하면 어떤 문제가 생겼는지 알려줄 수 있을 뿐 아니라 문제 생긴 이유도 설명할 수 있다. 여러 동작을 하나의 try/catch구문으로 감싸는 형태로, 연쇄적이고 중첩된 동작을 효율적으로 만들 수 있다.

7.3 예외 반환

타입스크립트는 자바가 아니기에 throw문을 지원하지 않는다. 하지만 유니온 타입을 이용해 비슷하게 흉내낼 수 있다.

function parse(birthday: string) : Data | InvaliDateFormatError | DateIsInTheFuterError{
	let date = new DAte(birthday)
    if(!isValid(date)){
    	return new InvaliDateFormatError('Enter a date in the form YYYY/MM/DD')
    }
    if (date.getTime() > Date.now()){
    	return new DateIsInTheFuterError('Are you a timelord?')
    }
    return date
}
  • parse의 시그니처에 발생할 수 있는 예외를 나열했다.
  • 메서드 사용자에게 어떤 에러가 발생할 수 있는지를 전달했다.
  • 메서드 사용자가 각각의 에러를 모두 처리하거나 다시 던지도록 강제했다.

한편 에러를 던지는 연산을 연쇄적으로 호출하거나 중첩하면 코드가 지저분해진다는 단점이 있다.

7.4 Option 타입

 특수 목전 데이터 타입을 사용해 예외를 표현하는 방법도 있다. 값과 에러의 유니온을 반환하는 방법에 비해 똑같은 데이터 타입을 사용하지 않는 다른 코드와 호환되지 않는다는 단점이 있지만 에러가 발생할 수 있는 계산에 여러 연산을 연쇄적으로 수행할 수 있게 된다.

 어떤 특정 값을 반환하는 대신 값을 포함하거나 포함하지 않을 수도 있는 컨테이너를 반환한다는 것이 Option 타입의 핵심이다. 컨테이너는 자체적으로 몇 가지 메서드를 제공하며, 개발자는 이를 이용해 설혹 안에 값이 없을지라도 여러 가지 연산을 연쇄적을 수행할 수 있다.

function ask() {
	let result = prompt('When is your brithday?')
    if (result === null){
    	return []
    }
    return [result]
}
// ...
ask()
	.map(parese)
    .map(date => date.toISOString()) // 에러:  속성'toISOString'는 'Date[]' 타입에 존재하지 않음
    
    // 배열의 배열을 배열로 평탄화하여 에러를 해결
function flatten<T>(array: T[][]): T[] {
	return Array.prorotype.caoncat.apply([],array)
}

flatten(ask()
	.map(parese)
    .map(date => date.toISOString()))

 현재 코드에서 타입이 많은 정보를 제공하지 않아 무슨 일이 벌어지고 있는지 한눈에 파악하기 어렵다. 우리가 하려는 작업을 컨테이너라는 특수한 데이터 타입에 담아 상황을 개선해보자. 이때 컨테이너는 대상 값을 이용해 연산을 수행하는 방법과 그 결과를 얻어내는 방법을 드러내는 역할을 한다.

 Option 타입은 다음과 같이 정의된다.

  • Option은 Some<T>와 None이 구현하게 될 인터페이스다. 두 클래스 모두 Option의 한 형태가 된다.
  • Some<T>는 T라는 값을 포함하는 Option이고 None은 값이 없는, 즉 실패한 상황의 Option을 가리킨다.
  • Option은 타입이기도 하고 함수이기도 하다. 타입 관점에서는 단순히 Some과 None의 슈퍼타입을, 함수 관점에서는 Option 타입의 새 값을 만드는 기능을 뜻한다.
interface Option<T>{
	flatMap<U>(f: (value: T) => Option<U>) : Option<U>
    getOrElse(value: T): T
}
class Some<T> implements Option<T> {
	constructor(private value : T){}
    flatMap<U>(f: (value: T) => Option<U>) : Option<U> { // 1
    	return f(this.value)
    }
    getOrElse() : T { // 2
    	return this.value
    }
}

class None implements Option<never>{
  	flatMap<U>() : Option<U> { // 3
    	return this
    }
    getOrElse<U>(value : U): U { // 4
    	return value
    }
}
  1. Some<T>에 flatMap을 호출하면 인수로 전달된 f를 호출해 새로운 타입의 새 Option을 만들어 반환한다.
  2. Some<T>에 getOrElse를 호출하면 Some<T>의 값을 반환한다.
  3. None은 계산 실패를 의미하므로 flatMap을 호출하면 항상 None을 반환한다.
  4. None에 getOrElse를 호출하면 항상 기본값으로 제공한 값을 그대로 반환한다.

 성공하거나 실패할 수 있는 연산을 연달아 수행할 때 Option을 유용하게 사용할 수 있다. 이는 타입 안정성을 제공할 뿐 아니라 타입 시스템을 통해 해당 연산이 실패할 수 있음을 사용자에게 알려줄 수 있다. 하지만 단점 역시 존재한다. 이 기능은 None으로 실패를 표현하기 때문에 무엇이 왜 실패했는지는 자세히 알려주지 못한다. 또한 Option을 사용하지 않는 다른 코드와 호환되지 않는다는 것도 단점이다.

7.5 요약

  • 어떤 작업이 실패했음을 단순히 알리거나 아니면 단순히 에러가 발생했을 떄 처리(null, 예외 처리) 
  • 가능한 모든 예외를 사용자가 명시적으로 처리하도록 강제하거나 (예외 반환)
  • 실패한 이유와 관련된 정보 제공,(예외 반환하거나 던지기)
  • 에러 처리 관련 코드를 더 적게 구현 (예외 던지기)
  • 에러를 만드는 방법이 필요하거나 (Option)
본 글은 Typescript Programming을 요약한 글입니다.
자세한 내요은 본 책을 읽으시길 바랍니다.

 

6장에서는 타입스크립트의 서브타입화, 할당성, 가변성, 넓히기 등을 소개하면서 타입스크립트 개념의 통찰을 발전시킨다. 그리고 정제, 종합성 등을 포함한 타입스크립트의 제어 흐름 기반의 타입 확인 기능도 자세히 살펴본다. 객체 타입을 키로 활용하고 매핑하는 방법, 조건부 타입 사용, 자신만의 타입 안전 장치 정의, 타입 어서션, 확실한 할당 어서션 등의 고급 개념도 살펴본다. 타입 안정성을 더 할 수있는 컴패니엉 객체 패턴, 튜플 타입의 추론 개선, 이름 기반 타입 흉내내기, 안전하게 프로토타입 확장하기 등 고급 패턴도 설명한다.

6.1 타입 간의 관계

6.1.1 서브타입과 슈퍼 타입

  • 서브 타입 : 두개의 타입 a와 b가 있고 b가 a의 서브타입이면 a가 필요한 곳에는 어디든 b를 안전하게 사용할 수 있다.
  • 슈퍼 타입 : 두개의 타입 a와 b가 있고 b가 a의 슈퍼타입이면 b가 필요한 곳에는 어디든 a를 안전하게 사용할 수 있다.
  • 슈퍼 타입과 서브 타입은 정반대로 동작한다.

6.1.2 가변성

매개변수화된(제네릭) 타입 등 복합 타입에서는 타입의 서브 타입을 판단하는 문제가 복잡해 진다. 이런 복합 타입의 서브타입 규칙은 프로그래밍 언어마다 다르며 같은 규칙을 가진 언어가 거의 없을 정도다. 이 규칙들을 더 쉽게 읽을 수 있도록 더욱 정확하고 간단하게 설명할 수 있는 몇 가지 문법을 설명한다.

  • A <: B 는 'A는 B와 같거나 B의 서브타입' 이라는 의미다.
  • A >: B 는 'A는 B와 같거나 B의 슈퍼타입' 이라는 의미다.

형태와 배열 가변성

1. 슈퍼 타입이 필요한 곳에 객체를 할당하는 경우

function deleteUser(user : {id? : number, name: string}){
	delete user.id
}

let existingUser : ExistingUser = {
	id : 1234,
    name : 'User'
}

deleteUser(existingUser)

 어떤 객체를 슈퍼타입을 기대하는 곳에 사용한다는 것은 분명 안전하지 않을 수 있다. 그러나 타입스크립트는 완벽함 보다는 실제 실수를 잡는 것과 쉬운 사용이라는 두 가지 목표를 균형있게 달성하는 것이 목표다. 그렇기에 타입스크립트는 에러를 발생시키지 않고 슈퍼타입이 필요한 곳에 객체를 할당할 수 있도록 허용한다.

 

2. 서브 타입이 필요한 곳에 객체를 할당하는 경우

type LegacyUser = {
	id?: number | string
    name : string
}

ley legacyUser: LegacyUser= {
	id : '793331',
    name : 'hwandu'
}

deleteUser(legacyUser) // 에러

기대하는 타입의 슈퍼타입의 프로퍼티를 포함하는 형태를 전달하면 타입스크립트는 에러를 발생시킨다. 어떤 형태를 요구할 때 건넬 수 있는 타입은, 요구되는 타입에 포함된 프로퍼티 각각에 대해 '<: 기대하는 타입'인 프로퍼티를 가지고 있어야 한다. 기대하는 프로퍼티의 슈퍼타입인 프로퍼티가 있다면 건넬 수 없다. 이를 타입스크립트 형태는 그들의 프로퍼티 타입에 공변한다고 말한다. 

 

3. 가변성 정리

  • 불면 : 정확히 T를 원함
  • 공변 : <:T를 원함 
  • 반변 : >:T를 원함
  • 양변 : <:T 또는 > :T 를 원함 

타입스크립에서 모든 복합 타입의 멤버(객체, 클래스, 배열, 함수, 반환 타입)는 공변 함수 매개변수 타입만 예외적으로 반변이다.

 

함수 가변성

함수 A가 함수 B와 같거나 적은 수의 매개변수를 가지며 다음을 만족하면 A는 B의 서브 타입이다.

  1. A의 this 타입을 따로 지정하지 않으면 'A의 this 타입 >: B의 this 타입'이다.
  2. 'A의 각 매개변수 >: B의 대응 매개변수'이다.
  3. 'A의 반환 타입 <: B의 반환 타입'이다.

왜 반환 타입만 조건이 반대일까? 객체, 배열, 유니온 등 과 달리 함수에서는 this, 매개변수, 반환 타입을 포함한 모든 컴포넌트가 <: 관계를 만족하지 않는 이유는 무엇일까? Crow <: Bird <: Animal 조건에서 예제를 실행해 보자.

function clone (f: (b : Bird) => Bird) : void {
	let parent = new Bird
    let babyBird = f(parent)
    babyBird.chirp()
}

clone에 Animal을 반환하는 함수 f를 전달한다면 f의 반환값에 .chrip를 호출할 수 없다. 함수의 반환 타입은 공변, 즉 함수가 다른 함수으 서브타입이라면 '서브타입 함수의 함수 반환 타입 <: 다른 함수의 반환 타입'를 만족해야한다. 매개변수 타입의 관계는 어떨까?

function animalToBird(a: Animal) : Bird {
	// ...
}

clone(animalToBird) // OK

function crowToBird(c: Crow) : Bird {
	// ...
}

clone(crowToBird) // 에러 : '(c: Crow) => Bird' 타입은 '(b: Bird) => Bird' 타입에 할당할 수 없음

함수를 다른 함수에 할당하려면 'this를 포함한 매개변수 타입 >: 할당하려는 함수의 대응 매개변수 타입' 조건을 만족해야한다.

function crowToBird(c: Crow): Bird {
	c.caw()
    return new Bird
}

clone에 crowToBird를 전달하면 .caw는 Crow에만 정의되어 있고 Bird에는 정의되어 있지 않으므로 예외가 발생한다. 즉 함수의 매개변수, this 타입은 반변이다. 한 함수가 다른 함수의 서브타입이라면 '서브타입 함수의 매개변수와 this 타입 >: 다른 함수의 대응하는 매개변수'라는 조건을 만족해야한다.

6.1.3 할당성

할당성이란 A라는 타입을 다른 B라는 타입이 필요한 곳에 사용할 수 있는지를 결정하는 타입스크립트 규칙을 말한다. 타입스크립트는 몇 가지 규칙에 따라 이를 처리한다. 배열, 불, 숫자, 객체, 함수, 클래스, 클래스. 인스턴스, 문자열, 리터럴 타입 등 열거형이 아닌 타입에서는 다음의 규칙을 A를 B에 할당할 수 있는지 결정한다.

  1. A <: B
  2. A는 any

규칙 1은 서브타입이 무엇인지 정의한다. 규칙 2는 예외를 설명하며 자바스크립트 코드와 상호 운용할 때 유용하다.

enum이나 const enum 키워드로 만드는 열거형 타입에서는 다음 조건 중 하나를 만족해야 A 타입을 열거형 B에 할당할 수 있다.

  1. A는 열거형 B의 멤버다.
  2. B는 number 타입의 멤버를 최소 한개 이상 가지고 있으며 A는 number이다.

규칙 1은 위 와 동일하며 규칙 2는 열거형을 처리할 때 편리하게 적용할 수 있지만 안정성이 많이 떨어지기에 열거형은 지양하도록 하자.

6.1.4 타입 넓히기

(let or var)로 값을 바꿀수 있는 변수를 선언하면  그변수의 타입이 리터럴 값에서 리터럴 값이 속한 기본 타입으로 넓혀진다.

let a = 'X' // string

값을 바꿀 수 없는 변수에서는 상황이 달라진다.

const a = 'x' // 'x'

타입을 명시하면 타입이 넓어지지 않도록 막을 수 있다.

let a : 'x' = 'x' // 'x'

값을 다시 할당하면 타입스크립트는 새로운 값에 맞게 변수의 타입을 넓힌다. 명시적으로 타입 어노테이션을 추가하면 자동 확장은 일어나지 않는다.

const a = 'x' // 'x'
let b = a // string

const c : 'x' = 'x' // 'x'
let d = c // 'x'

null 이나 undefined로 초기화된 변수는 any 타입으로 넓혀진다.

let a = null // undefined

null 이나 underfined로 초기화된 변수가 선언 범위를 벗어나면 타입스크립트는 확실한 타입을 할당한다.

function x() {
	let a = null // any
    a = 3 // any
    a = 'b' // any
    return a
}

x() // string

const 타입

타입이 넓혀지지 않도록 해주는 const라는 특별 타입을 제공한다. const를 사용하면 타입 넓히기가 중지되며 멤버들까지 자동으로 readonly가 된다.(중첩된 자료구조에도 재귀적으로 적용한다.) 변수를 가능한 좁은 타입으로 추론하길 원한다면 as const를 이용하자.

let e = [1, {x : 2}] as const // readonly [1, {x : 2}]

 

초과 프로퍼티 확인

 객체 타입과 그 멤버들은 공변 관계라고 설명했다. 그런데 추가 확인을 수행하지 않고 이 규칙만을 적용하면 문제가 발생할 수 있다. 예를 들어 객체 내 프로퍼티의 철자를 틀리면 어떤 일이 일어날까 ?

type Options = {
	baseURL : string
    cacheSize? : number
    tier? :'prod' | 'dev'
}

new API ({
	baseUrl : 'http://api...'
    tierr : 'prod' // 에러 '{tierr: string}'타입은 파라미터 타입 'Option'에 할당할 수 없음
})

 타입 스크립트가 이를 검출 할 수 있었던 건 초과 프로퍼티 확인 기능 덕분이다. 신선한 객체 리터럴 타입 T를 다른 타입 U에 할당하려는 상황에서 T가 U에 존재하지 않는 프로퍼티를 가지고 있다면 타입스크립트는 이를 에러로 처리한다. 여기서 신선한 객체 리터럴 타입이란 타입스크립트가 객체 리터럴로부터 추론한 타입을 가리킨다. 객체 리터럴이 타입 어션을 사용하거나 변수로 할당되면 이는 일반 객체 타입으로 넓혀지면서 신선함은 사라진다.

6.1.5 정제

 타입스크립트는 심벌 수행의 일종인 흐름 기반 타입 추론을 수행한다. if, ?, ||, switch 같은 제어 흐름 문장까지 고려하여 타입을 정제한다.

 

차별된 유니온 타입

type UserTextEvent = {value : string, target: HTMLInputElement}
type UserMouseEvent = {value : [string, string], target: HTMLElement}

type UserEvent = UserTextEvent|UserMouseEvent

function handle(event: UserEvent) {
	if(typeof event.value === 'string'){
    	event.value // string
        event.target // HTMLInputElement|HTMLElement !!
        // ...
        return
    }
    event.value // [string, string]
    event.value // HTMLInputElement|HTMLElement !!
}

event.value는 잘 정제되었지만 event.target에는 적용되지 않았다.HTMLInputElement|HTMLElement 은 전자나 후자만 전달할 수 있다는 의미가 아니다. 사실 HTMLInputElement|HTMLElement 타입의 인수도 전달 할 수 있다. 유니온의 멤버가 서로 중복될 수 있으므로 타입스크립트는 유니온의 어떤 타입에 해당하는지를 조금 더 안정적으로 파악할 수 있어야한다.

리터럴 타입을 이용해 유니온 타입을 만들어낼 수 있는 각각의 경우를 태그하는 방식으로 이 문제를 해결할 수 있다. 다음은 좋은 태그의 조건이다.

  • 유니온 타입의 각 경우와 같은 위치에 있다. 객체타입 에서는 같은 객체 필드를, 튜플 타입이면 같은 인덱스를 의미한다.
  • 리터럴 타입이다. 한가지의 타입만 사용하는 것이 바람직하며 보통 문자열 리터럴 타입을 사용한다.
  • 제네릭이 아니다.
  • 상호 배타적이다.
type UserTextEvent = {
	type: 'TextEvent',
    value : string, 
    target: HTMLInputElement
    }
type UserMouseEvent = {
	type: 'MouseEvent',
    value : [string, string],
    target: HTMLElement
}

type UserEvent = UserTextEvent|UserMouseEvent

function handle(event: UserEvent) {
	if(event.type === 'TextEvet'){
    	event.value // string
        event.target // HTMLInputElement
        // ...
        return
    }
    event.value // [string, string]
    event.target // HTMLElement 
}

6.2 종합성

철저 검서라고도 불리는 종합성은 피요한 모든 상황을 제대로 처리했는지 타입 검사기가 검사하는 기능이다. 예를 들어, 코드의 반환문이 없다면 에러메세지를 띄워서 코드에서 처리하지 않는 상황이 있거나 어떤 상홍이든 대처할 수 있는 반환문을 함수 마지막에 추가해야 한다고 말해준다. switch, if ,throw 등 어떤 구조를 사용 하든 타입스크립트는 모든 상황을 다 고려했는지 확인한다.

6.3 고급 객체 타입

6.3.1 객체 타입의 타입 연산자

키인 연산자

복잡하게 중첩된 타입이 있다고 가정하자.

type APIResponse = {
	user : {
    	userId: string
        friendList: {
        	count : number
            friends:{
            	firstName: string
                lastName: string
            }[]
        }
    }
}

이 Api로 부터 응답을 받아 보여줘야할 떄 friendList는 어떤 타입이여야 할까? 각 최상위 타입에 쓰는 이름들을 하나하나 다 만들어야하는데 우리는 이런 응답 타입에 키인하는 방법이 있다.

type FriendList = APIResponse['user']['frinedList']
type Friend = FriendList['freinds'][number]

모든 형태와 배열에 키인할 수 있다.  number가 배열 타입에 키인을 적용하는 핵심이다. 튜플에서는 0,1 또는 키인하려는 인덱스를 가리키는 숫자 리터럴 타입을 사용할 수 있다. 일반 자바스크립트 객체의 필드를 찾는 것처럼 타입을 찾을 수 있다.

 

keyof 연산자

 keyof를 이용하면 객체의 모든 키를 문자열 리터럴 타입 유니온으로 얻을 수 있다.

type FriendListKeys = keyof APIResponse['user']['frinedList'] // 'count' | 'friends'

키인과 keyof 연산자를 혼합해 사용하면 객체에서 주어진 키를 해당하는 값을 반환하는 게터를  안전한 방식으로 구현할 수 있다.

6.3.2 Record 타입

내장 Record 타입을 이용하면 무언가를 매핑하는 용도로 객체를 활용할 수 있다. Record 타입은 객체가 특정 키 집합을 정의하도록 하는 두 가지 방법 중 하나다.

let nextDay : Record<Weekday, Day> = {
	Mon : 'Tue'
} // 에러 {Mon : 'Tue'} 타입에는 Record<Weekday, Day> 중 Tue, Wed, Thu, Fri가 빠져 있음

Record는 일반 객체의 인덱스 시그니처에 비해 자유롭다. 인덱스 시그니처에서는 객체 값의 타입은 제한할 수 있지만, 키는 반드시 일반 string, number, symbol이어야 한다. 하지만 Record에서는 객체의 키 타입도 string과 number의 서브타입으로 제한할 수 있다.

6.3.3 매핑된 타입

객체가 특정 키 집합을 정의하도록 하는 두 가지 방법 중 더 강력한 방법은 매핑된 타입을 이용하는 것이다.

let nextDay : {[K in WeekDay]: Day}= {
	Mon : 'Tue'
} // 에러 {Mon : 'Tue'} 타입은 '{Mon: Weekday; Tue: Weekday; Wed: Weekday; Thu: Weekday; Fri: Weekday;}' 중 Tue, Wed, Thu, Fri를 포함하지 않음

예제와 같이 매핑된 타입은 고유 문법이 있다. 인덱스 시그니처와 마찬가지로 한 객체 당 최대 한 개의 매핑된 타입을 가질 수 있다.

매핑된 타입은 Record보다 강력하다. 객체의 키와 값에 타입을 제공할 뿐 아니라, 키인 타입과 조합하면 키 이름별로 매핑할 수 있는 값 타입을 제한할 수 있기 때문이다.

 

내장 매핑된 타입

유도한 매핑된 타입은 매우 유용하여 이 중 많은 것들을 타입스크립트가 내장 타입으로 제공한다.

  • Record<Keys, value> : Keys 타입의 키와 Values 타입의 값을 갖는 객체
  • Partial<Object> : Object의 모든 필드를 선택형으로 표시
  • Required<Object> : object의 모든 필드를 필수형으로 표시
  • Readonly<Object> : object의 모든 필드를 읽기 전용으로 표시
  • Pick<Object, Keys> : 주어진 Keys에 대응하는 Object의 서브타입을 반환

6.3.4 컴패니언 객체 패턴

 타입스크립트에는 타입과 객체를 쌍으로 묶는 컴패니언 객체 패턴이 존재한다.

type Currency = {
	unit : 'EUR' | 'GBP' | 'JPY' |'USD'
    value : number
}

let Currency = {
	DEFAULT: 'USD',
    from(value: number, unit= Currency.DEFAULT) : Currency{
    	return {unit, value}
    }
}

 타입스크립트에서는 타입과 값은 별도의 네임스페이스를 갖느다는 사실을 기억하자. 따라서 영역에서 하나의 이름을 타입과 값 모두에 연결할 수 있다. 한편, 컴패니언 객체 패턴을 이용하면 별도의 네임스페이스를 이용해 한번은 타입으로, 한 번은 값으로 두 번 이름을 선언할 수 있다. 이 패턴을 이용하면 타입과 값 정보를 Currency 같은 한 개의 이름으로 그룹화 할 수도 있고 호출자는 이 둘을 한번에 import할 수 있다.

6.4 고급 함수 타입들

6.4.1 튜플의 타입 추론 개선

타입스크립트는 튜플을 선언할 때 튜플의 타입에 대해 관대한 편이다. 길이, 위치, 타입 등을 무시하고 주어진 상황에서 제공할 수 있는 가장일반적인 타입으로 튜플의 타입을 추론한다.

let a = [1, true] // (number | true)[]

 하지만 때로는 좀 더 엄격한 추론이 필요하다. 물론 타입 어서션이나 as const 어서션을 이용해 좁게 추론할 수 있다. 그러나 이를 제외하고 튜플을 튜플 타입을 만들려면 나머지 매개변수의 타입을 추론하는 기법을 이용하면 된다.

function tuple<
	T extends unknown[] // 1
>(
	...ts: T  // 2
): T {
	return ts // 3
}

let a = tuple(1,true) // [number, boolean]
  1. unkwon[]의 서브타입인 단일 타입 매개변수 T를 선언한다.
  2. tuple은 임의 개수의 매개변수 ts를 받는다. T는 나머지 매개변수를 나타내므로 이를 튜플 타입으로 추론한다.
  3. tuple 함수는 ts의 추론 타입과 같은 튜플 타입의 값을 반환한다.

튜플 타입이 많이 등장한다면 이러한 기법을 활용해 타입 어서션 사용을 줄일 수 있다.

6.4.2 사용자 정의 타입 안전 장치

타입 정제는 강력하지만 현재 영역에 속한 변수만을 처리할 수 있다는 문제가 존재한다. 한 영역에서 다른 영역으로 이동하면 기존의 정제 결과물은 사라져버린다. 사용자 정의 타입 안정 장치라는 기법으로 이를 해결할 수 있다.

function isString(a: unknown) : a is string {
	return typeof a === 'string'
}

 사용자 정의 타입 안정 장치는 매개 변수 하나에만 적용할 수 있지만 복합 타입에도 적용할 수 있다.

function isLegacyDialog(
	dialog : LegacyDialog | Dialog
) : dialog is LegacyDialog {
	// ...
}

 사용자 정의 타입 안정 장치를 사용하지 않으면  isLegacyDialog와 isString 같은 캡술화되고 가독성 좋은 함수를 활용하지 못하고, 대신 typeof나 instanceof 타입 안전 장치를 코드에 일일이 추가 해야한다.

6.5 조건부 타입

조건부 타입의 의미를 말로 풀어본다면 "U와 V 타입에 의존하는 T 타입을 선언하라. U <: V 면 T를 A에 할당하고, 그렇지 않으면 T를 B에 할당하라"라고 해석할 수 있다.

type IsString<T> = T extends string // 1
	? true // 2
    : false // 3
    
type A = IsString<string> // true
type B = IsString<number> // false
  1. 이 조건부 타입에서 조건은 T extends string 부분이고, "T는 string의 서브 타입인가?"라는 의미다.
  2. T가 string의 서브타입이면 true 타입으로 해석한다.
  3. 그렇지 않으면 false 타입으로 해석한다.

위 타입 수준의 연산은 일반적인 삼항 연산자처럼 중첩할 수 도 있다. 조건부 타입은 타입 별칭 외에도 타입을 사용할 수 있는 거의 모든 곳 (타입 별칭, 인터페이스, 클래스, 매개변수 타입, 함수와 메서드의 제네릭 기본값 등)에 사용할 수 있다.

6.5.1 분배적 조건부

타입스크립트는 분배 법칙을 따르기에 간단한 조건을 다양한 방식으로 표현할 수 있다.

이 표현식은 ... 다음과 같다.
(stirng | number) extends T ? A : B (stirng extends T ? A : B) | (number extends T ? A : B
type ToArray<T> = T[]
type A = ToArray<number> // number []
type B = ToArray<number | string> // (numbner | string)[]

type ToArray2<T> = T extends unknown ? T[] : T[]
type A = ToArray2<number> // number []
type B = ToArray2<number | string> // numbner[] | string[]

 조건부 타입을 사용하면 타입스크립트는 유니온 타입을 조건부의 절들로 분배한다. 이를 이용하면 다양한 공통 연산을 안전하게 표현할 수 있다. T에는 존재하지만 U에는 존재하지 않는 타입을 구하는 Without<T,U>도 구현 할 수 있다.

type Without<T, U> = T extends U ? never : T

type A = Without<
	boolean | numebr | string,
    boolean
> // number | string

6.5.2 infer 키워드

조건부 타입의 마지막 특성으로 조건의 일부를 제네릭 타입으로 선언할 수 있는 기능을 꼽을 수 있다. 조건부 타입에서는 제네릭 타입을 인라인으로 선언하는 전용 문법을 제공한다. 바로 infer 키워드다.

type ElementType<T> = T extends unknown[] ? T[number] : T
type A = ElementType<number[]> // number

type ElementType2<T> = T extends (infer U)[] ? U : T
type B = ElementType<number[]> // number

infer 문은 어떤 T를 전달했느냐를 보고 U의 타입을 추론한다. 

6.5.3 내장 조건부 타입들

조건부 타입을 이용하면 강력한 연산자 몇 가지를 타입수준에서 표현할 수 있다.

  • Exclude<T,U> : Without 타입처럼 T에 속하지만 U에는 없는 타입을 구한다.
  • Extract<T,U> : T의 타입 중 U에 할당할 수 있는 타입을 구한다.
  • NonNullable<T> : T에서 null과 undefined를 제외한 버전을 구한다.
  • RetrunType<F> : 함수의 반환 타입을 구한다 (제네릭과 오버로드된 함수에는 동작 X)
  • InstanceType<C> : 클래스 생성자의 타입을 구한다.

6.6 탈출구

 상황에 따라서는 타입을 완벽하게 지정하지 않고도 어떤 작업이 안전하다는 사실을 타입스크립트가 믿도록 만들고 싶을 때가 있다.

6.6.1 타입 어서션

타입 B가 있고 A <: B <: C르 만족하려면 타입 검사기에게 B는 실제로 A거나 C라고 어서션할 수 있다. 주의할 점은, 어떤 하나의 타입은 자신의 슈퍼타입이나 서브타입으로만 어서션할 수 있다. 타입스크립트는 두 가지의 타입 어서션 문법을 제공한다.

formatInput(Input as string)

formatInput(<string>input)

두 타입 사이에 연광성이 충분하지 않아서 한 타입을 다른 타입이라고 어서션 할 수 없을 때도 있다. 이 문제는 any라고 어서션하여 우회가능하지만 안전하지 않기에 되도록 피해야한다.

6.6.2 Nonnull 어서션

널이 될 수 있는 특별한 상황(T | null 또는 T | null | undefined 타입)을 대비해 타입스크립트는 어떤 값의 타입이 null이나 undefined가 아니라 T임을 단언하는 특수 문법을 제공한다.

type Dialog = {
	id? : string
}

function closeDialog(dialog : Dialog) {
	if(!dialog.id){
    	return
    }
    setTimeour(()=>
    	removeFromDOM(
        	dialog,
            document.getElementById(dialog.id) // 에러 'string | undefine' 타입의 인수는 'string' 타입의 매개변수에 할당할 수 없음
        )
    )
}

function removeFromDom(dialog: Dialog, element : Element) {
	element.parentNode.removeChild(element) // 에러 : 객체가 null일 수 있음
}
  • 화살표 함수 내부에서 유효범위가 바뀐다. 위에서 정제가 무효화 되고 타입스크립트 입장에서는 getElementById를 호출하면 HTMLElement | null을 반환한다는 사실만 알고 있을 뿐이다.

필요한 모든 곳에 if _ === null 을 추가해 이 문제를 해결할 수 있다. 대상이 null 인지 여부를 확실할 수 없다면 올바른 해법이다. 하지만 대상이 null | undefined가 아님을 확실하는 경우라면 타입스크립트가 제공하는 특별 문법을 활용할 수 있다.

type Dialog = {
	id? : string
}

function closeDialog(dialog : Dialog) {
	if(!dialog.id){
    	return
    }
    setTimeour(()=>
    	removeFromDOM(
        	dialog,
            document.getElementById(dialog.id)!
        )
    )
}

function removeFromDom(dialog: Dialog, element : Element) {
	element.parentNode!.removeChild(element) 
}

간간히 보이는 nonull 어서션 연사자 ! 로 document.getElementById의 호출 결과인 dialog.id와 element.parentNode가 정의되어 있음을 타입스크립트에 알려주었다. nonull 어서션를 너무 많이 사용하고 있다면 코드를 리팩토링 해야한다는 징후일 수 있다. Dialog를 두 타입의 유니온으로 분리해 어서션을 제거할 수 있다.

6.6.3 확실한 할당 어서션

타입스크립트는 확실한 할당 검사용으로 nonnull 어서션을 적용하는 특별한 상황에 사용할 특수 문법을 제공한다. 확실한 할당 어서션을 이용하여 userId를 사용하는 시점에는 이 변수가 반드시 할당되어 있을 것임을 타입스크립트에 알려줄 수 있다.

let userId! : string
fetchuser()

userId.toUpperCase() // OK

6.7 이름 기반 타입 흉내내기

타입스크립트는 이름 기반 타입을 제공하지 않지만 타입 브랜딩이라는 기법으로 이르 흉내낼 수 있다. 브랜디드 타입을 이용하면 프로그램을 한층 안전하게 만들 수 있다.

type CompanyID = string & {readonly brand : unique symbol}
type OrderID = string & {readonly brand : unique symbol}
type UserID = string & {readonly brand : unique symbol}
type ID = CompanyID | OrderID | UserID
  1. 예제에서 unique symbol을 브랜드로 사용했는데 이는 타입 스크립트에서 실질적으로 제공하는 두 가지 이름 기반 타입 중 하나이기 때문이다.
  2. 그런 다음 이 브랜드를 string과 인터섹션하여 주어진 문자열이 우리가 정의한 브랜디드 타입과 같다고 어서션할 수 있도록 했다.
function CompanyID (id : string){
	return id as CompanyID
}

function OrderID (id : string){
	return id as OrderID
}

function UserID (id : string){
	return id as UserID
}

let companyId = CompanyID('8a6076cf')

캠피니언 객체 패턴을 사용하여 각 브랜디드 타입의 생성자를 만든다. 주어진 값 id를 앞서 정의한 난해한 타입들로 지정하는 타입 어서션 as을 이용한다.

6.8 프로토타입 안전하게 확장하기

예전에는 프로토타입 확장이 안전하지 않은 일이었지만 이제 타입스크립트처럼 정적 타입 시스템을 이용하면 안전하게 확장할 수 있다.

본 글은 Typescript Programming을 요약한 글입니다.
자세한 내요은 본 책을 읽으시길 바랍니다.

 

 객체 지향 프로그래밍 언어에서 클래스는 코드를 조직하고 이해할 수 있는 방법을 제공할 뿐만 아니라 캡슐화의 주요 단위기도 하다. 타입스크립트의 클래스는 가시성 한정자, 프로퍼티 초기자, 다형성, 데코레이터, 인터페이스 등을 제공한다. 또한 믹스인 같은 자바스크립트 표현식도 타입 안정성을 유지하며 사용할 수 있다.

 프로퍼티 초기자와 데코레이터는 자바스크립트에사도 지원하므로 실제 런타임 코드를 생성한다. 반면, 가시정 접근자, 인터페이스, 제네릭 등은 고유 기능이므로 컴파일 타임에만 존재하며 아무 코드도 생성하지 않는다. 5장에선 클래스를 활용하는 방법과 타입스크립트의 객체 지향 언어 기능이 무엇인지 살펴본다.

5.1 클래스와 상속

타입스크립트는 클래스의 프로퍼티와 메서드에 세 가지 접근 한정자를 제공한다.

  • public : 어디에서나 접근할 수 있다. 기본 값이다.
  • protected : 이 클래스와 서브클래스의 인스턴스에서만 접근할 수 있다.
  • private : 이 클래스의 인스턴스에서만 접근할 수 있다.

접근 한정자를 이용해 내부 구현 정보를 많이 공개하지 않고 잘 정의된 API만 노출하도록 클래스를 설계할 수 있다.

abstract 키워드는 해당 클래스를 바로 인스턴스화할 수 없음을 의미할 뿐 필요한 메서드를 추상 클래스에 자유롭게 추가할 수 있다.

// ...
abstract class Piece {
	// ...
    moveTo(position : Position) {
    	this.position = position
    }
    abstract canMoveTo(position : Position): boolean
}

Piece 클래스는 다음과 같은 의미로 해석할 수 있다.

  • canMoveTo라는 케서드를 주어진 시그니처와 호환되도록 구현해야 함을 하위 클래스에 알린다. 추상 클래스를 구현할 때는 추상 메서드도 반드시 구현해야 한다.
  • moveTo의 기본 구현을 포한한다.

이번장을 요약하자면

  • class 키워드로 클래스를 선언 후 extends 키워드로 다른 클래스를 상속 받을 수 있다.
  • 클래스는 구체 클래스와 추상 클래스로 구분된다.
  • 메서드는 private, protected, public 중 한 가지 한정자를 갖는다.
  • 클래스틑 인스턴스 프로퍼티도 가질 수 있으며 이들은 private, protected, public 중 한 가지 한정자를 갖는다. 생성자의 매개변수나 프로퍼티 초기자에도 이들 한정자를 사용할 수 있다.
  • 인스턴스 프로퍼티를 선언할 때 readonly를 추가할 수 있다.

5.2 super

자식 클래스가 부모 클래스에 정의된 메서드를 오버라이드하면 자식 인스턴스는 super를 이용해 부모 버전의 메서드를 호출할 수 있다.

  • super.take 같은 메서드 호출
  • 생성자 함수에서만 호출할 수 있는 super()라는 특별한 타입의 생성자 호출

super로 부모 클래스의 메서드에만 접근할 수 있고 프로퍼티에는 접근할 수 없다는 사실을 기억하자.

5.3 this를 반환 타입으로 사용하기

this를 값 뿐 아니라 타입으로도 사용할 수 있다. 클래스를 정의할 때라면 메서드의 반환 타입을 지정할 때 this 타입을 유용하게 활용할 수 있다. 클래스를 상속받는 서브클래스가 this를 반환하는 모든 메서드의 시그니처를 오버라이드해야하는 경우가 생긴다. 이때 반환 타입을 this로 지정하면 이를 타입스크립트가 알아서 해준다.

class Set {
	has(value: number): boolean {
    	// ...
    }
    add(value: number): this {
		// ...
    }
}

이제 set의 this는 Set 인스턴스를, MutableSet의 this는 MutableSet 인스턴르르 자동으로 가리키므로 MutableSet에서 add 메서드를 오버라이드할 필요가 없다.

class MutableSet extends Set {
	delete(value: number): boolean{
    	// ...
    }
}

5.4 인터페이스

 타입 별칭처럼 인터페이스도 타입에 이름을 지어주는 수단이므로 인터페이스를 사용하면 타입을 더 깔끔하게 정의할 수 있다.

type Sushi = {
	calories: numnber
    salty: boolean
    tasty: boolean
}

interface Sushi = {
	calories: numnber
    salty: boolean
    tasty: boolean
}

 Sushi 타입 별칭을 사용한 모든 곳에 Sushi 인터페이스를 대신 사용할 수 잇다. 둘 다 형태를 정의하며 두 형태 정의는 사로 할당할 수 있다.

type Food = {
	calories: number
    tasty: boolean
}

type Sushi = Food & {
	salty: number
}

interface Food = {
	calories: number
    tasty: boolean
}

interface Sushi extends Food {
	salty: number
}

 타입과 인터페이스의 사이에는 미묘한 차이가 존재한다. 

  1. 타입 별칭은 더 일반적이어서 타입 별칭의 오른편에는 타입 표현식을 포함한 모든 타입이 등장할 수 있다. 반면 인스턴스의 오른편에는 반드시 형태가 나와야한다. 다음과 같은 코드는 인터페이스로 작성 불가능하다. (type A =number, type B = A | string)
  2. 인터페이스를 상송할 때 타입스크립트는 상속받는 인터페이스의 타입에 상위 인스턴스를 할당할 수 있는지를 확인한다. 인터페이스는 엄격하게 할당을 검사하는 반면 인터섹션 타입을 사용한다면 타입스크립트는 확장하는 타입을 최대한 조합하는 방향으로 동작한다. 컴파일 에러가 발생하는 인터페이스의 경우와 달리 오버로드한 시그니처가 만들어진다.
  3. 이름과 범위가 같은 인터페이스가 여러 개 있다면 이들이 자동으로 합쳐진다.

5.4.1 선언 합침

 선언 합침은 같은 이름으로 정의된 여러 정의를 자동으로 합치는 타입스크립트의 기능이다. User라는 똑같은 이름의 인터페이스를 두 개 정의하면 타입스크립트는 자동으로 둘을 하나의 인터페이스로 합친다. 타입 별칭은 똑같은 이름으로 두 개를 만들 수 없다. 한편, 인터페이스끼리는 충돌해서는 안 된다. 한 타입의 프로퍼티는 T와 다른 타입의 프로퍼티 U가 동일하지 않다면 에러가 발생한다. 제네릭을 선언한 인터페이스들의 경우 제네릭들의 선언 방법과 이름까지 똑같아야 합칠 수 있다.

5.4.2 구현

클래스를 선언할 때 implements라는 키워드를 이용해 특정 인터페이스를 만족시킴을 표현할 수 있다.

interface Animal {
	eat(food: string): void
    sleee(hour: number): void
}

class Cat implements Animal {
	eat(food: string){
    	console.info('Ate some', food, ',Mmm!')
    }
    sleee(hour: number){
    	console.info('Slept for', hours, 'hours')
    }
}

 Cat은 Animal이 선언하는 모든 메서드를 구현해야 하며, 필요하다면 메서드나 프로퍼티를 추가로 구현할 수 있다. 인터페이스로 인스턴스 프로퍼티를 정의할 수 있지만 가시성 한정자는 선언할 수 없으며 static 키워드도 사용할 수 없다. 인스턴스 프로퍼티를 readonly로 설정할 수 있다. 필요하다면 한 클래스가 여러 인터페이스를 구현할 수도 있다.

interface Feline {
	meow(): void
}

class Cat implements Animal, Feline {
	// ...
    
   	meow() {
    	conosle.info('Meow')
    }
}

5.4.3 인터페이스 구축 vs. 추상 클래스 구축

 인터페이스는 형태를 정의하는 수단이다. 값 수준에서 이는 객체, 배열, 함수, 클래스, 클래스 인스턴스를 정의할 수 있다는 뜻이다. 아무런 자바스크립트 코드를 만들지 않으며 컴파일 타임에만 존재한다.

 추상 클래스는 오직 클래스만 정의할 수 있다. 이는 런타임의 자바스크립트 클래스 코드를 만든다. 생성자와 기본 구현을 가질 수 있으며 프로퍼티와 메서드에 한정 접근자를 지정할 수 있다.

 여러 클래스를 공유하는 구현이라면 추상 클래스를 사용하고, 가볍게 클래스를 미리 말하는 것이 목적이라면 인터페이스를 사용하자.

5.5 클래스는 구조 기반 타입을 지원한다.

타입스크립트는 클래스를 비교할 떄 다른 타입과 달리 이름이 아니라 구조를 기준으로 삼는다. 클래스는 자신과 똑같은 프로퍼티와 메서드를 정의하는 기존의 일반 객체를 포함해 클래스의 형태를 공유하는 다른 모든 타입과 호환된다.

class Zebra {
	trot() {
    	// ...
    }
}

class Poodle  {
	trot() {
    	// ...
    }
}

function ambleAround(animal: Zebra){
	animal.trot()
}

let zebra = new Zebra
let poodle = new Poodle

ambleAroud(zebra) // Ok
ambleAroud(poodle) // OK

 함수의 관점에서 두 클래스는 .trot를 구현하며 서로 호환되므로 아무 문제없이 함수가 실행된다. 단, private이나 protected 필드를 갖는 클래스는 상황이 다르다. 클래스에 private이나 protected 필드가 있고, 할당하려는 클래스가 서브클래스의 인스턴스가 아니라면 할당할 수 없다고 판정한다.

5.6 클래스는 값과 타입을 모두 선언하다

  클래스와 열거형은 특별하다. 이들은 타입 네임 스페이스에 타입을, 값 네임스페이스에 값을 동시에 생성한다는 점에서 특별하다.

class C {}
let c : C // 1 
	= new C //2
    
enum E {F, G}
let e: E // 3
	= E.F // 4
  1. 문맥상 C는 C 클래스의 인스턴스르 가리킨다.
  2. 문맥상 C는 값 C를 가리킨다.
  3. 문맥상 E는 E 열거형의 타입을 가리킨다.
  4. 문맥상 E는 값 E를 가리킨다.

클래스를 다룰 때는 '이 변수는 이 클래스의 인스턴스여야 한다'라고 표현할 수 있는 방법이 필요한데, 이는. 열거형도 마찬가지다. 클래스와 열거형은 타입 수준에서 타입을 생성하기 때문에 이런 'is-a' 관계를 쉽게 표현할 수 있다.

위 예제에서 C는 C 클래스의 인스턴스를 가리켰다. C 클래스 자체는 어떻게 가리킬 수 잇을까? typeof 키워드를 사용한다. (자바스크립트의 값 수준의 typeof 키워드가 있듯이 타입스크립트에서는 타입 수준의 typeof가 존재한다.)

type State = {
	[key: stirng] : string
}

class StringBasedata {
	state: State = {}
    get(key: string): string | null {
    	// ...
    }
    set(key: string, value: string): void {
    	// ...
    }
    static from(state: State) {
    	// ...
        return db
    }
    
}

// 인스턴스 타입
interface StringDatabase {
	state: State
    get(key: string): string | null
    set(key: string, value: string): void
}

// typeof StringDatabase의 생성자 타입
interface StringDatabaseConstructor {
	new(): StringDatabase
    from(state: State): StringDatabase
}

new() 코드를 생성자 시그니처라 부르며, 생성자 시그니처는 new 연산자로 해당 타입을 인스턴스화할 수 잇음을 정의하는 타입스크립트의 방식이다. 클래스 정의는 용어의 값 수준과 타입 수준으로 생성할 뿐만 아니라, 타입수준에서는 두 개의 용어를 생성했다. 하나는 클래스의 인스턴스를 가리키며, 다른 하나는 클래스 생성자 자체를 가리킨다.

5.7 다형성

클래스와 인터페이스도 기본값과 상한/하한 설정을 포함한 다양한 제너릭 타입 매개변수 기능을 지원한다.

class MyMap<K,V> { // 1
	constructor (initialKey: K, initialValue: V) { // 2
    	// ... 
    }
    get(key: K): V { // 3
    	// ...
    }
    set(key: K, value: V): void {
    	// ...
    }
	merge<K1, V1>(map: MyMap<K1, V1>): MyMap<K | K1, V | V1>{ // 4
    	// ... 
    }
    statid of<K,V>(k:K, v:V): MyMap<K,V>{ // 5
    	// ...
    }
}
  1. class와 함께 제네릭을 선언했으므로 클래스 전체에서 타입을 사용할 수 있다.
  2. constructor에는 제네릭 타입을 선언할 수 없다.
  3. 클래스로 한전된 제네릭 타입은 클래스 내부 어디에서나 사용할 수 있다.
  4. 인스턴스 메서드는 클래스 수준의 제네릭을 사용할 수 있으며 자신만의 제네릭도 추가로 선언할 수 있다. K1, V1를 추가로 선언했다.
  5. 정적 메서드는 클래스의 인스턴스 변수에 값 수준에서 접근할 수 없듯이 클래스 수준의 제네릭을 사용할 수 없다. 자신만의 K,V를 직접 선언했다.
interface MyMap<K,V>{
	get(key: K) : V
    set(key: K, value: V) : void
}

인터페이스에도 제네릭을 사용할 수 있다.

5.8 믹스인

자바스크립트와 타입스크립트는 trait나 mixin 키워드를 제공하지 않지만 손 쉽게 직접 구현할 수 있다. 두 키뭐드 모두 둘 이상의 클래스를 상속받는 다중상속과 관련된 기능을 제공하며, 역할 지향 프로그래밍을 제공한다. 믹스인이란 동작과 프로퍼티를 클래스로 혼합할 수 있게 해주는 패턴으로 다음 규칙을 따른다.

  • 상태를 가질 수있다. ex: 인스턴스 프로퍼티
  • 구체 메서드만 제공할 수 있다.
  • 생성자를 가질 수 있다.

5.9 데코레이터

데코레이터는 타입스크립트의 실험적 기능으로 클래스, 클래스 메서드, 프로퍼티, 메서드 매개변수를 활용한 메타 프로그래밍에 깔끔한 문법을 제공한다. 장식하는 대상의 함수를 호출하는 기능을 제공하는 문법이다.

@serializable
class APIPayload{
	getValue(): Payload {
    	// ...
    }
}

클래스 데코레이터인 @serializable은 APIPayload 클래스를 감싸고 있으며 선택적으로 이를 대체하는 새 클래스를 반환한다.

 타입스크립트는 데코레이터 타입 각각에 대해 주어진 이름 범위에 존재하는 함수와 해당 데코레이터 타입에 요구되는 시그니처를 필요로 한다. 타입스크립트가 기본으로 제공하는 데코레이터는 없다. 직접 구현하거나 NPM으로 설치해야 한다. 모든 종류의 데코레터는 특정 시그니처를 만족하는 일반 함수일 뿐이다.

타입스크립트는 데코레이터가 장식하는 대상의 형태를 바꾸지 않는다고, 즉 메서드나 프로퍼티를 추가하거나 삭제하지 않았다고 가정한다. 반환된 클래스를 전달된 클래스에 할당할 수 있는지 컴파일 타임에만 확인하며, 코드를 작성할 때는 데코레이터가 어떻게 확장되는지 추적하지 않는다. 기능이 더 완벽해지기 전까지는 데코레이터 대신 일반 함수를 사용할 것을 권장한다.

5.10 final 클래스 흉내내기

final 키워드는 클래스나 메서드를 확장하거나 오버라이드할 수 없게 만드는 기능이다. 타입스크립트의 비공개 생성자 흉내낼 수 있다.

class MessageQueue {
	private constructor(private messages: string[])
}

 생성자를 private으로 선언하면 new로 인스턴스를 생성하거나 클래스를 확장할 수 없게된다. 반면 final 클래스는 상속만 막을 뿐 인스턴스는 정상적으로 만들 수 있다. 다음과 같이 한다면 쉽게 해결할 수 있다.

class MessageQueue {
	private constructor(private messages: string[])
    static create(message: string[]){
    	return new MessageQueue(messages)
    }
}

5.11 디자인 패턴

5.11.1 팩토리 패턴

 팩토리 팬던은 어떤 객체를 만들지를 전적으로 팩토리에 위임하는 패턴이다.

type Shoe = {
	purpose: string
}

class BalletFlat implements Shoe {
	purpose: 'dancing'
}


class Boot implements Shoe {
	purpose: 'woodcutting'
}

class Sneaker implements Shoe {
	purpose: 'waliking'
}

let Shoe = {
	create(type : 'BalletFlat', 'Boot', 'Sneaker') : Shoe { // 1
    	switch (type) { // 2
        	case 'BalletFlat' return new BalletFlat
            case 'Boot return new Boot
            case 'Sneaker' return new Sneaker
        }
    }
}
  1. type을 유니온 타입으로 지정해서 컴파일 타임에 호출자가 유효하지 않은 type을 전달하지 못하도록 방지하였다
  2. switch문을 이용해 누락된 Shoe타입이 없는지 타입스크립트가 쉽게 확인할 수 있게 하였다.

5.11.2 빌더 패턴

빌더 패턴으로 객체의 생성과 객체 구현 방식을 분리할 수 있다.

class RequestBulilder {
	private url: string | null = null // 1
    
    setURl(url: string): this { // 2
    	this.url = url
        return this
    }
}
  1. url 이라는 비공개 변수로 사용자가 설정한 URL를 추적한다.
  2. setURL의 반환 타입은 this다. 즉, 사용자가 호출한 특정 RequestBuilder의 인스턴스다.

5.12 마치며

  1. 클래스 선언 방법
  2. 클래스 상속과 인터페이스 구현방법
  3. 클래스를 인스턴스화할 수 없도록 abstract 추가하는 방법
  4. 클래스의 필드와 메서드에 static을 추가하고 인스턴스에는 추가하지 않는 방법
  5. pricate, protected, public 가시성 한정자로 필드와 메서드의 접근을 제어하는 방법
  6. readonly 한정자로 필드에 값을 기록할 수 없게 만드는 방법
  7. this와 super를 안전하게 사용하는 방법
  8. this와 super 이들이 클래스의 값과 타입 모두에 어떤 의미 인지
  9. 타입 별칭과 인터페이스의 차이
  10. 선언 합치기 개념
  11. 클래스에 제네릭 타입을 사용하는 방법
  12. 믹스인, 데코레이터, final 클래스 흉내내기
  13. 팩토리 팩턴과 빌더 패턴 알아보기

을 5장에서 배웠다.

본 글은 Typescript Programming을 요약한 글입니다.
자세한 내요은 본 책을 읽으시길 바랍니다.

 

이번 장에선 다음과 같은 내용을 살펴본다.

  • 타입스크립트에서 함수를 선언하고 실행하는 다양한 방법
  • 시그니처 오버로딩
  • 다형적 함수
  • 다형적 타입 별칭

4.1 함수 선언과 호출

자바스크립트에서 함수는 일급 객체다. 즉, 객체를 다루듯이 함수를 다룰 수 있다.

function add(a : number, b : number) : number {
 return a + b
}

 보통 함수 매개변수의 타입은 명시적으로 정의한다. 타입스크립트는 항상 함수의 본문에서 사용된 타입들을 추론하지만 특별한 상황을 제외하면 매개변수 타입은 추론하지 않는다. 반환 타입은 자동으로 추론하지만 원한다면 명시할 수 있다.

 자바스크립트와 타입스크립트는 최소 다섯 가지의 함수 선언 방법을 지원한다.

// 이름 붙인 함수
function greet(name : string){
	return 'hello' + name
}

// 함수 표현식
let greet2 = function (name : string){
	return 'hello' + name
}

// 화살표 함수 표현식
let greet3 = (name : string) => {
	return 'hello' + name
}

// 단축형 화살표 함수 표현식
let greet4 =(name : string) =>
	return 'hello' + name


// 함수 생성자
let greet5 = new function('name', 'return "hello" + name')

 타입스크립트는 함수 생성자를 제외한 모든 문법을 안전하게 지원하며 이 모든 문법은 보통 매개변수 타입의 필수 어노테이션, 반환 타입의 선택형 어노테이션에 적용하는 것과 같은 규칙을 따른다. 타입스크립트에서 함수를 호출할 때 타입 정보는 따로 제공할 필요가 없으며, 바로 인수를 전달하면 타입스크립트가 함수의 매개변수와 인수의 타입이 호환되는지 확인한다.

4.1.1 선택적 매개변수와 기본 매개변수

함수에서도 ?를 이용해 선택적 매개변수를 지정할 수 있다. 함수의 매개변수를 선언할 때 필수 매개변수를 먼저 지정하고 선택적 매개변수를 뒤에 추가한다.

function log (message : string, userId? : string){
	let time = new Date().toLocalTimeString()
    console.log(time, message, userID || 'Not singed in')
}

log('Page loaded') // "12:38:31 PM Page loaded Not singed in" 출력

log('User signed in', 'da763be') // "12:38:32 PM User signed in da763be" 출력

자바스크립트에서처럼 매개변수에 기본값을 지정할 수 있다. 의미상으로는 호출자가 해당 매개변수에 값을 전달하지 않아도 되므로 매개변수를 선택적으로 만드는 것과 같다.(선택적 매개변수는 뒤에 와야 하지만 기본 매개변수는 어디에나 추가할 수 있다는 점이 다르다.)

function log (message : string, userId='Not singed in'){
	let time = new Date().toLocalTimeString()
    console.log(time, message, userId)
}

기본값 제공으로 인해 선택형 마크와 타입을 지정할 필요가 없어졌다. 덕분에 코드가 간결해지고 읽기도 쉬워진다. 물론 일반 매개변수에 타입을 지정하듯이 기본 매개변수에도 타입을 지정할 수 있다.

type Context = {
	appID? : string
    userID? : string
}

function log (message : string, context : Context ={}){
	let time = new Date().toLocalTimeString()
    console.log(time, message, context.userId)
}

4.1.2 나머지 매개변수

인수를 여러개 받는 함수라면 그 목록을 베열 형태로 건넬 수도 있다.

function sum(numbers : number[]):number{
	return numbers.reduce((total, n) => total + n, 0)
}

  떄로는 인수의 개수가 고정된 고정 인자 API가 아니라 인수의 개수가 달라질 수 있는 가변 인자 API가 필요할 때도 있다. 자바스크립트는 런타임이 함수에 자동으로 arguments를 정의해 개발자가 함수로 전달한 인수 목록을 할당할 수 있도록 기능을 제공한다. arguments는 일종의 배열이므로 .reduce와 같은 내장 기능을 사용하러면 먼저 진짜 배열로 변환해야한다.

function sumVariadic() : number {
	return Array
    	   .from(arguments)
           .reduce((total,n)=> total + n, 0)
}

 하지만 arguments는 전혀 안전하지 않다. 타입스크립트는 n 과 total를  모두 any 타입으로 추론했고 이를 실행시킨다면 함수가 인수를 받지 않도록 선언했으므로 타입스크립트의 입장에선 인수를 받을 수 없다면서 타입 에러를 발생시킨다.

 그렇다면 안전한 타입의 가변 인수 함수를 어떻게 만들 수 있을까? 나머지 매개변수로 이 문제를 해결할 수 있다. 나머지 매개변수를 이용해 sum 함수가 안전하게 임의의 인수를 받게 만든다.

function sumVariadicSafe(...number : numebr[]) : number {
	return number.reduce((total,n)=> total + n, 0)
}

 함수는 최대 한개의 나머지 매개변수를 가질 수 있으며 나머지 매개변수는 함수의 매개변수 목록 맨 마지막에 위치해야 한다.

4.1.3 call, apply, bind

함수를 괄호 ()로 호출하는 방법도 있지만 자바스크립트는 두가지 방법을 추가로 제공한다.

function add(a : number, b : number) : number {
 return a + b
}

add(10,20) // 30으로 평가
add.apply(null,[10,20]) // 30으로 평가
add.call(null, 10,20) // 30으로 평가
add.bind(,null 10,20)() // 30으로 평가
  • apply는 함수 안에서 값을 this로 한정하며 (위 에시에서는 this를 null로 한정) 두 번째 인수를 펼쳐 함수에 매개변수로 전달한다.
  • call도 같은 기능을 수행하지만 인수를 펼쳐 전달하지 않고 순서대로 전달한다는 점이 다르다.
  • bind도 this 인수를 함수의 인수 목록으로 한정한다. 다른점은 bind는 함수를 호출하지 않고 새로운 함수를 반환하는데, 개발자는 ()나 .call을 이용해 반환된 함수를 호출하거나 .apply로 아직 한정하지 않은 매개 변수를 추가로 전달 할 수 있다.

4.1.4 this의 타입

 자바스크립트에서 this 변수는 클래스에 속한 메서드들 뿐만 아니라 모든 함수에서 정의된다. this의 값은 함수를 어떻게 호출했는 지에 따라 달라지는데 이는 자바스크립트를 어렵게 만드는 고질병 중 하나다.

 this가 자주 문제를 일으키는 원인은 할당 방법에 있다. 메서드를 호출할 때 this는 . 점 왼쪽에의 값을 갖는다는 것이 일반적인 원칙이다.

let x ={
 a() {
 	return this
 }
}

x.a() // a()의 바디 안에서 this는 객체 x임

하지만 호출이 일어나기 전 어느 시점에서 a를 다시 할당하면 결과가 달라진다 !

let a = x.a
a() // 이제 a()의 바디 안에서 this는 정의되지 않은 상태임!

다음처럼 날짜의 타입을 포매팅하는 유틸리티 함수가 있다고 가정하자.

function fancyDate(){
	return ${this.getDate()}/${this.getMonth()}/${this.getFullYear()}
}

이 버전의 fancyDate를 호출할며녀 this로 한정할 Date를 제공해야한다. 깜빡하고 Date를 한정하지 않으면 런타임 예외가 발생한다.

fancyDate.call(new Date) // "5/20/2024"로 평가

fancyDate() // TypeError : this.getDate는 함수가 아님

 여기서는 this의 동작은 예상과 크게 다를 수 있다는 점만 짚고 넘어가자. 어떻게 선언하느냐가 아니라 함수를 어떻게 호출하느냐에 영향을 받는다. 다행히 타입스크립트는 이 문제를 잘 처리해준다. 함수에서 this를 사용할 떄는 항상 기대하는 this 타입을 함수의 첫 번째 매개변수로 선언하자. 그러면 함수 안에 등장하는 모든 this가 의도한 this가 됨을 타입스크립트가 보장해준다. 함수 시그니쳐에 사용한 this는 예약어이므로 다른 매개변수와 완전히 다른 방식으로 처리된다.

function fancyDate(this : Date){
	return ${this.getDate()}/${this.getMonth()}/${this.getFullYear()}
}

fancyDate.call(new Date)
fancyDate() // 에러 TS2684 : void 타입의 'this'를 메서드에 속한 'Date'타입의 'this'에 할당할 수 없음

4.1.5 제네레이터 함수

제네레이터 함수는 여러 개의 값을 생성하는 편리한 기능을 제공한다. 이를 이용하면 값을 생상하는 속도도 정교하게 조절할 수 있다. 제네레이터는 요청해야만 다음 값을 계산하기 때문에 무한의 목록 등 생성하기 까다로운 기능을 제공할 수 있다.

function* createFionacciGenenrator() { // 1
	let a = 0
    let b = 1
    while (true) { // 2
    	yield a; // 3
        [a,b] = [b,a + b] // 4
    }
}

let fionacciGenenrator = createFionacciGenenrator()
						// IterableIterator<number>
fionacciGenenrator.next() // {value : 0, done : false} 로 평가
fionacciGenenrator.next() // {value : 1, done : false} 로 평가
fionacciGenenrator.next() // {value : 1, done : false} 로 평가
fionacciGenenrator.next() // {value : 2, done : false} 로 평가
fionacciGenenrator.next() // {value : 3, done : false} 로 평가
fionacciGenenrator.next() // {value : 5, done : false} 로 평가
  1. 함수명 앞의 *는 함수는 제네레이터임을 의미한다. 호출하면 이터러블 반복자가 반환된다.
  2. 영구적으로 값을 생성할 수 있다.
  3. yield라는 키워드로 값을 생성할 수 있다. 다음 값을 요청하면 (예 : next 호출), yield를 이용해 결과를 소비자에게 보내고, 다음 값을 다시 요청하기 전까지는 실행을 중지한다. 이런 방식으로 동작하므로 무한 루프에 걸릴 가능성이 존재하지 않는다.
  4. 피보나치 숫자를 계산하기 위해 a에 b를 , b에 a + b를 한번에 다시 할당한다.

 타입스크립트가 방출된 값의 타입을 이용해 반복자의 타입을 추론함을 알 수 있다.

4.1.6 반복자

반복자는 제네레이터는 상생관계다. 제너레이터로 값의 스트림을 생성할 수 있고 반복자로 생성된 값을 소비할 수 있기 때문이다.

  • 이터러블 : Symbol.iterator 라는 프로퍼티, 반복자를 반환 함수를 가진 모든 객체
  • 반복자 : next라는 메서드(value, done 두 프로퍼티를 가진 객체를 반환)를 정의한 객체

 가령 createFionacciGenenrator 함수를 호출하면 Symbol.iterator 프로퍼티와 next 메서드를 모두 정의한 값을 얻게 된다. 즉, 이터러블과 반복자 두 가지가 결합된 제너레이터가 반환된다. 

 Symbol.iterator 프로퍼티와 next 메서드를 구현하는 객체 또는 클래스를 만들어 반복자나 이터러블을 직접 정의할 수 있다.

let numbers = {
	*[Symbol.iterator](){
    	for(let n = 1; n <=10; n++){
        	yield n
        }
    }
}

 반복자 코드를 코드 편집기에 입력한 다음 마우스를 올려놓으면 타입스크립트가 반복자 코드의 타입을 어떻게 추론하는지 확인할 수 있다. number는 이터러블이며, 제너레이터 함수 numbers[Symbol.iterator]()를 호출하면 이터러블 반복자가 반환된다.

4.1.7 호출 시그니처

함수의 전체 타입을 표현하는 방법을 알아보자.

function sum(a : number, b : number) : number {
 return a + b
}

 sum의 타입은 Function이다. Function은 모든 함수의 타입을 뜻할 뿐이며 그것이 가리키는 특정 함수와 타입과 관련된 정보는 아무것도 알려주지 않는다. 그렇다면 이를 다르게 표현할 수 있는 방법은 무엇일까? sum은 두 개의 number를 인수로 받아 한 개의 number를 반환하는 함수다. 다음과 같이 표현할 수 있다.

(a : number, b : number) => number

  이 코드는 타입스크립트의 함수 타입 문법으로, 호출 시그니쳐 또는 타입 시그니쳐라 부른다. 함수에 함수를 인수로 전달하거나 함수에서 다른 함수를 반환하는 경우 이 문법으로 인수나 반환 함수의 타입을 지정할 수 있다. 함수 호출 시그니쳐는 타입 수준 코드, 즉 값이 아닌 타입 정보만 포함한다. 이는 함수 호출 시그니처로 매개변수 타입, this 타입, 반환 타입, 나머지 타입, 조건부 타입을 표현할 수 있지만 기본값은 표현할 수 없다. 또한 바디를 포함하지 않아 타입스크립트가 타입을 추론할 수 없으므로 반환 타입을 명시해야 한다.

  • 타입 수준 코드와 값 수준 코드
    타입 수준코드는 타입과 타입 연산을 포함하는 코드를 의미한다. 반면 값 수준 코드는 그 밖의 모든 것을 가리킨다.
    어떤 코드가 유효한 자바스크립트 코드라면 값 수준이고, 유효한 자바스크립트 코드는 아니지만 유효한 타입스크립트 코드라면 타입 수준으로 쉽게 구분 가능하다.
 // greet(name : string) 함수
type Greet = (name : string) => string

 // log(message : string, userId? : string) 함수
type Log = (message:string, userId?: string)

 // sumVariadicSafe(...number : number[]) : number 함수
type SumVariadicSafe = (...number : number[]) => number

 함수의 호출 시그니쳐는 구현 코드와 거의 같다. 언어 설계상 의도한 결정으로, 쉽게 호출 시그니처를 추론할 수 있다. 호출 시그니처와 구현의 관계를 더 구체적으로 확인하자. 호출 시그니처가 주어졌을 때 어떻게 그 시그니처를 만족하는 함수를 구현할 수 있을까? 간단하게 호출 시그니처를 함수 표현식과 합칠 수 있다.

type Log = (message : string, userId? : string) => void 
// 기존 함수를 새로운 시그니처에 맞게 다시 구현해보자

let log : log = ( // 1
	message, // 2
    userId = 'Not singed In' // 3
) => {// 4
	let time = new Date().toISString()
    console.log(time,message,userId)
}
  1. 함수 표현식 log를 선언하면서 log 타입임을 명시했다.
  2. Log에서 message의 타입을 string으로 이미 명시했으므로  매개변수의 타입을 다시 지정할 필요는 없다. 
  3. userId에 기본값을 지정한다. 호출 시그니처는 값을 포함할 수 없으므로 Log에서는 userId의 타입은 지정할 수 있지만 기본값은 지정할 수 없기 때문이다.
  4. Log 타입에서 반환 타입을 void로 지정했으므로 반환 타입은 다시 지정할 필요가 없다.

4.1.8 문맥적 타입화

위 에시는 문맥적 타입화라는 타입스크립트의 강력한 추론 기능으로 매개변수의 타입을 명시하지 않아도 되었다.

function times(
	f : (index : number) => void,
    n : number
) {
	for (let i = 0; i < n; i ++){
    	f(i)
    }
}

 times를 호출할 떄 함수 선언을 인라인으로 제공하면 인수로 전달하는 함수의 타입을 명시할 필요가 없다.

times(n => console.log(n), 4)

times의 시그니처에서 f의 인수 index를 number로 선언했으므로 타입스크립트는 문맥상 n의 number임을 추론할 수 있다. 
f 를 인라인으로 선언하지 않으면 타입스크립트는 타입을 추론할 수 없다.

function f(n) { // 에러 TS7006 : 매개변수 'n'의 타입은 암묵적으로 'any'타입이 됨
	console.log(n)
}

4.1.9 오버로드된 함수 타입

 이전 절에서 사용한 함수 타입 문법은 단축형 호출 시그니처다. 이를 더욱 명확하게 표현할 수 있다.

// 단축형 호출 시그니쳐
type Log = (message : string, userId? : string) => void

// 전체 호출 시그니쳐
type Log = {
	(message : string, userId? : string): void
}

두 코드는 문법만 조금 다를 뿐 모든 면에서 같다. 간단한 상황이라면 단축형을 주로 사용하되 더 복잡한 함수라면 전체 시그니처를 사용하는 것이 좋을 때도 있다. 바로 함수 타입의 오버로딩이 좋은 예이다. 

  • 오버로드된 함수 : 호출 시그니처가 여러 개인 함수

 자바스크립트는 동적언어이므로 어떤 함수를 호출하는 방법이 여러 가지다. 뿐만 아니라 인수 입력 타입에 따라 반환 타입이 달라질 때도 있다! 타입스크립트는 이런 동적 특징을 오버로드된 함수 선언으로 제공하고, 입력 타입에 따라 달라지는 함수의 출력 타입은 정적 타입 시스템으로 각각 제공한다. => 타입 시스템의 고급 기능에 속한다. 오버로드 된 함수 시그니처를 사용하면 표현력 높은 API를 설계할 수 있다.

// 휴가 예약 API를 예시로 만들어 보자

type Reserve = {
	(from: Date, to: Date, destination: string) : Resevation
}

let reserve : Reserve = (from, to, destination) => {
	// ...
}

다음 처럼 편도 여향을 지원하도록 API를 개선할 수 있다.

type Reserve = {
	(from: Date, to: Date, destination: string) : Resevation
   	(from: Date, destination: string) : Resevation
}

 그러나 이를 실행할려고 하면 타입스크립트가 에러를 발생시킨다. 이 문제는 타입스크립트가 호출 시그니처 오버로딩을 처리하는 방식 때문에 발생한다. 함수 f에 여러 개의 오버로드 시그니처를 선언하면, 호출자 관점에서 f의 타입은 이들 오버로드 시그니처들의 유니온이 된다.
 하지만 f를 구현하는 관점에서 단일한 구현으로 조합된 타입을 나타낼 수 있어야 한다. 이 조합된 시그니처는 자동으로 추론되지 않으므로 f를 구현할 때 직접 선언해야 한다.

type Reserve = {
	(from: Date, to: Date, destination: string) : Resevation
   	(from: Date, destination: string) : Resevation
} // 1

// 함수를 다음과 같이 바꿀 수 있다.
let reserve : Reserve = (
	from : Date,
    toOrDestination : Date | string,
    destination? : string
) => { // 2
	// ...
}
  1. 오버로드된 함수 시그니처 두 개를 선언한다.
  2. 구현의 시그니처는 두 개의 오버로드 시그니처를 수동으로 결합한 결과와 같다. (즉, Signature 1 | Signature 2를 손으로 계산).
    결합된 시그니처는 reserve를 호출하는 함수에는 보이지 않는다. 즉, 다음은 소비자 관점의 Reserve 시그니처다.
type Reserve = {
	(from: Date, to: Date, destination: string) : Resevation
   	(from: Date, destination: string) : Resevation
}

 결과적으로 이전에 정의한 결합된 시그니처를 모두 포함하지 않는다.

// 잘못됨 !!
type Reserve = {
	(from: Date, to: Date, destination: string) : Resevation
   	(from: Date, destination: string) : Resevation
    (from: Date, toOrDestination: Date | string,
    	Destination? : string) : Resevation
}

 두 가지 방식으로 reserve를 호출할 수 있으므로 reserve를 구현할 때 타입스트립트에 어떤 방식으로 호출되는 지 확인 시켜 주어야한다.

let reserve : Reserve = (
	from : Date,
    toOrDestination : Date | string,
    destination? : string
) => { 
	if(toOrDestination intstanceif Date && destination !== undefined){
    	// 편도 여행 예약
    } else if (typeof toOrDestination === 'string'){
    	// 왕복 여행 예약
    }
}
  • 오버로드 시그니처는 구체적으로 유지하자
    오버로드 된 함수 타입을 선언할 때는 각 오버로드 시그니처를 구현의 시그니처에 할당할 수 있어야 한다. 즉, 오버로드를 할당할 수 있는 범위에서 구현의 시그니처를 얼마든지 일반화할 수 있다.
    오버로드를 사용할 떄는 함수를 쉽게 구현할 수 있도록 가능한 한 구현의 시그니처를 특정하는 것이 좋다. 예를 들어 any 대신 특정한 타입을 사용할 수있다.
    any 타입으로 받은 매개변수를 Date로 사용하고자 한다면 먼저  그 값이 실제로 날짜임을 타입스크립트에 증명해야한다. 그러나 Date타입임을 미리 명시해두면 구현 시 일이 줄어든다.

오버로드는 자연스럽게 브라우저 DOM API에서 유용하게 활용된다. 새로운 HTML 요소를 만들 때 사용하는 createElement DOM API를 예를 들어보자. 이 API는 HTML 태그에 해당하는 문자열을 받아 이 태그 타입의 새 HTML 요소를 반환한다.

type CreateElement = {
	(tag : 'a') : HTMLAnchorElement // 1
    (tag : 'canvas') : HTMLCanvasElement
    (tag : 'table') : HTMLTableElement
    (tag : string) : HTMLElement // 2
}

let createElement : CreateElement = (tag : string) : HTMLElement => { // 3
	// ...
}
  1. 매개변수는 문자열 리터럴 타입으로 오버로드했다.
  2. 오버로드에 지정되지 않은 문자열을 createElement로 전달하면 타입스크립트는 이를 HTMLElement로 분류한다.
  3. 구현의 매개변수는 createElement의 오버로드 시그니처가 가질수 있는 모든 매개변수 타입을 합친 타입 ('a' | 'canvas' | 'table' | string)지원해야한다.
    세 개의 문자열 리터럴 타입은 모두 string의 서브 타입이므로 간단하게 타입 유니온 결과를 string으로 축약할 수 있다.
  • 함수 선언을 오버로드하고 싶다면 동일한 문법을 제공한다.
function createElement(tag : 'a') : HTMLAnchorElement
function createElement(tag : 'a') : HTMLAnchorElement
function createElement(tag : 'canvas') : HTMLCanvasElement
function createElement(tag : 'table') : HTMLTableElement
function createElement(tag : string) : HTMLElement {
	// ...
}

 

 전체 타입 시그니처를 함수 호출 방식 오버로딩에만 사용할 수 있는 것은 아니며 함수의 프로퍼티를 만드는 데도 사용할 수 있다.

function warnUser(warning){
	if(warnUser.wasCalled){
    	return
    }
    warnUser.wasCalled = true
    alert(warning)
}
wasUser.wasCalled = false

type WarnUser = {
	(warning : string) : void
    wasCalled : boolean
}

wasUser는 호출할 수 있는 함수인 불리언 속성인 wasCalled도 가지고 있다.

4.2 다형성

구체 타입이란 지금까지 우리가 살펴본 모든 타입이 구체 타입이다. 기대하는 타입을 정확하게 알고 있고, 실제 이 타입이 전달되었는지 확인할 때는 구체 타입이 유용하다. 하지만 때로는 어떤 타입을 사용할지 미리 알 수 없는 상황이 있는데 이런 상황에서는 함수를 특정 타입으로 제한하기 어렵다. 
 filter라는 함수를 예로 만들며 살펴보자.

function filter(array, f) {
	let result = []
    for (let i = 0; i < array.length ; i++){
    	let item = array[i]
        if(f(item)){
        	result.push(item)
        }
    }
    return result
}

 filter의 전체 타입 시그니처를 unknown에서 number로 가정하여 바꾼다.

type Filter = {
	(array : number[], f : (item : number)=> boolean) : number[]
}

 filter가 범용함수, 즉 숫자, 문자열, 객체, 배열, 기타 모든 것으로 구성된 배열을 거스를 수 있도록 오버로드를 이용해 함수를 확장해보면 코드가 지져분해질 겉다는 점만 제외하면 문제가 없어보인다. 그러나 객체 배열도 지원할 수 있을까?

type Filter = {
	(array : number[], f : (item : number)=> boolean) : number[]
    (array : string[], f : (item : string)=> boolean) : string[]
    (array : object[], f : (item : object)=> boolean) : object[]
}

문제가 없이 실행될것 처럼 보이지만 에러가 발생한다. 객체 배열 시그니처 대로 함수를 구현하고 실행해 보자

let name = [
	{firstName : 'beth'},
    {firstName : 'caitlyn'},
    {firstName : 'xin'},
]

let result = filter(
	names,
    _ => _.firstName.startsWith('b')
) // 에러 TS2339: 'firstName' 프로퍼티는 'object' 타입에 존재하지 않음

result[0].firstName // 에러 TS2339: 'firstName' 프로퍼티는 'object' 타입에 존재하지 않음

 object는 객체의 실제 형태에 대해서는 어떤 정보도 알려주지 않는다는 사실을 기억하자. 따라서 배열에 저장된 객체의 프로퍼티에 접근하려 시도하면 타입스크립트 에러가 발생한다. 배열에 저장된 객체의 형태를 우리가 알려주지 않았기 때문이다. 이 문제를 해결할 수 있는 것이 제네릭 타입이다.

  • 제네릭 타입 매개변수 : 여러 장소에 타입 수준에 제한을 적용할 떄 사용하는 플레이스홀더 타입 = 다형성 타입 매개변수
type Filter = {
	<T>(array:T[], f : (item: T)=> boolean) : T[]
}

 위는 이 타입이 무엇인지 지금 알 수 없으니 누군가 filter를 호출할 때마다 타입스크립트가 타입을 추론해주기 바란다라는 뜻이다. 전달된 array의 타입을 보고 T의 타입을 추론한다. filter를 호출한 시점에 타입스크립트가 T의 타입을 추론해내면 filter에 정의된 모든 T를 추론한 타입으로 대체한다. T는 자리를 맡아둔다는 의미의 '플레이스홀더' 타입이며, 타입 검사기가 문맥을 보고 이 플레이스홀더 타입을 실제 타입으로 채우는 것이다. 이처럼 T는 filter의 타입을 매개변수화한다.

 꺽쇠 괄호 <> 로 제네릭 타입 매개변수임을 선언한다. 꺽쇠 기호를 추가하는 위치에 따라 제네릭의 범위가 결정되며 타입스크립트는 지정된 영역에 속하는 모든 제네릭 타입 매개변수 인스턴스가 한 개의 구체 타입으로 한정되도록 보장한다.  필요하면 꺾쇠괄호 안에 제네릭 타입 매개변수 여러 개를 콤마로 구분해 선언할 수 있다.

 제네릵은 함수의 기능을 더 일반화하여 설명할 수 있는 강력한 도구다. 제네릭을 제한 기능으로 생각할 수 있다. 제네릭 T는 T로 한정하는 타입이 무엇이든 모든 T를 같은 타입으로 제한한다.

4.2.1 언제 제네릭 타입이 한정되는가?

제네릭 타입의 선언 위치에 따라 타입의 범위뿐만 아니라 타입스크립트가 제네릭 타입을 언제 구체 타입으로 한정하는지도 결정된다.

type Filter = {
	<T>(array:T[], f : (item: T)=> boolean) : T[]
}

let filter : Filter = (array,f) =>
	// ...

 

 이 예에서는 <T>를 호출 시그니처의 일부로 (시그니처를 여는 괄호 바로 앞에) 선언했으므로 타입스크립트는 Filter 타입의 함수를 실제 호출할 때 구체 타입을 T로 한정한다. 이와 달리 T의 범위를 Filter의 타입 별칭으로 한정하러면 Filter를 사용할 떄 타입을 명시적으로 한정해야 한다.

type Filter = {
	<T>(array:T[], f : (item: T)=> boolean) : T[]
}

let filter : Filter = (array, f) => // 에러 TS2314 : 제네릭 타입 'Filter'는 한 개의 타입 인수를 요구함

type OtherFilter = Filter // 에러 TS2314 : 제네릭 타입 'Filter'는 한 개의 타입 인수를 요구함

let filter : Filter<number> = (array, f) => 
	// ...
    
type StringFilter = Filter<string>
let stringFilter: StringFilter = (array, f) =>
	// ...

 타입스크립트는 제네릭 타입을 사용하는 순간에 제네릭과 구체 타입을 한정한다. 제네릭을 사용할때란 함수에서는 함수를 호출할 떄, 클래스라면 클래스를 인스턴스화할 떄, 타입 별칭과 인터페이스에서는 이들을 사용하거나 구현할 때를 가리킨다.

4.2.2 제네릭을 어디에 선언할 수 있을까?

타입스크립트에서는 호출 시그니처를 정의하는 방법에 따라 제네릭을 추가하는 방법이 정해져 있다.

type Filter = { // 1
	<T>(array: T[], f : (item: T) => boolean) : T[]
}
let filter : Filter = // ...

type Filter<T> = { // 2
	(array: T[], f : (item: T) => boolean) : T[]
}
let filter : Filter<number> = // ...

type Filter = <T>(array: T[], f : (item: T) => boolean) : T[] // 3
let filter : Filter = // ...

type Filter<T> = <T>(array: T[], f : (item: T) => boolean) : T[] // 4
let filter : Filter<T> = // ...

function filter<T>(array : T[], f : (item: T)=> boolean): T[] { // 5
	// ...
}
  1. T의 범위를 개별 시그니처로한정한 전체 호출 시그니처. 각각의 filter 호출은 자신만의 T 한정 값을 갖는다.
  2. T의 범위를 모든 시그니처로 한정한 전체 호출 시그니처. 타입스크립트는 Filter 타입의 함수를 선언할 떄 한정한다.
  3. 1과 비슷하지만 전체 시그니처가 아닌 단축 호출 시그니처 문법.
  4. 2과 비슷하지만 전체 시그니처가 아닌 단축 호출 시그니처 문법.
  5. T를 시그니처 범위로 한정한, 이름을 갖는 함수 호출 시그니처. 각 filter 호출은 자신만의 T 한정 값을 갖는다.

 map 함수를 구현하여 예시를 들어보자.

function map(array : T[], f : (item : T) => U) : U[] {
	let result = []
    for (let i = 0; i < array.length; i ++){
    	result[i] = f(array[i])
    }
	return result
}

 인수 배열 멤버의 타입을 대변하는 T, 반환 배열 멤버 타입을 대변하는 U, 이렇게 두 가지 제네릭 타입이 필요하다. T 타입의 요소를 포함하는 배열을 전달하면 매핑 함수가 T 타입의 값을 가지고 U 타입의 값을 변환한다. 그리고 최종적으로 U 타입의 함목을 포함하는 배열을 반환한다.

4.2.3 제네릭 타입 추론

대부분의 상황에서 타입스크립트는 제네릭 타입을 추론해낸다. 그러나 제네릭도 명시적으로 지정할 수 있다. 제네릭의 타입을 명시할 때는 모든 필요한 제네릭 타입을 명시하거나 반대로 아무것도 명시해서는 안 된다.

map<string,boolean>(
['a','b','c'],
_ => _ === 'a'
) // OKAY

map<string>(
['a','b','c'],
_ => _ === 'b'
) // 에러 TS 2558: 두 개의 타입 인수가 필요한데 한 개만 전달됨

타입스크립트는 추론된 각 제네릭 타입을 명시적으로 한정한 제네릭에 할당할 수 있는지 확인한다. 할당할 수 없으면 에러가 발생한다.

// boolean은 boolean | string에 할당할 수 있으므로 OK
map<string,boolean | string>(
['a','b','c'],
_ => _ === 'a'
)

map<string,number>(
['a','b','c'],
_ => _ === 'b'
) // 에러 TS 2322: 'boolean'타입은 'number'에 할당할 수 없음

 타입스크립트는 제네릭 함수로 전달한 인수의 정보를 이용해 제네릭의 구체 타입을 추론하므로 다음과 같은 에러가 발생할 수도 있다.

let promise = new Promise(resolve => 
	resolve(45)
)

promise.then(result => // {}로 추론함
	result * 4	// 에러 TS2362 : 수학 연산의 왼쪽 연산자는 'any', 'numebr', 'bigint', enum 타입 중 하나여야 함
)

let promise = new Promise<number>(resolve => 
	resolve(45)
)

promise.then(result => // {}로 추론함
	result * 4	
)

 타입스크립트는 제네릭 함수의 인수에만 의지하여 제네릭 타입을 추론하는데 인수가 아무것도 없으니 기본적으로 T를 {}로 간주하여 에러가 발생하였다. 제네릭 타입 매개변수를 명시해서 해결할 수 있다.

4.2.4 제네릭 타입 별칭

타입 별칭에 제네릭을 활용하는 방법을 자세히 살펴보자.

type MyEvent<T> = {
	target: T
    type: string
}

type ButtonEvent = MyEvent<HTMLButtonElement>

 타입 별칭에서는 타입 별칭명과 할당 기호 사이에만 제네릭 타입을 선언할 수 있다. MyEvent 같은 제네릭 타입을 사용할 때는 타입이 자동으로 추론되지 않으므로 타입 매개 변수를 명시적으로 지정해줘야 한다.

let myEvent : Event<HTMLButtonElement | null> = {
	targer: document.querySelector('#myButton'),
    type: 'click'
}

제네릭 타입 별칭을 함수 시그니처에도 사용할 수 있다. 타입 스크립트는 구체 타입 T로 한정하는 동시에 MyEvent에도 적용한다.

function triggerEvent<T>(event: MyEvent<T>) : void {
	// ...
}

triggerEvent({ // T는 Element | null
	traget : document.querySelector('#myButton'),
    type : 'mouseover'
})
  1. 객체에 triggerEvent를 호출한다.
  2. 함수 시그니처를 통해 MyEvent<T>타입임을 파악된다. 이 타입이 {target: T, type: string}으로 정의 됐다는 것도 파악된다.
  3. 호출자가 전달한 객체의 target 필드가 document.querySelector('#myButton')임이 파악된다.
  4. T의 타입이 document.querySelector('#myButton') 이며 이는 Element | null 타입으로 한정된다.
  5. 타입스크립트가 T를 Element | null로 대체한다.

4.2.5 한정된 다형성

  때로는 U타입은 적어도 T타입을 포함하는 기능이 필요하다. 이런 상황을 U가 T의 상한 한계라고 설명한다. 예는 다음과 같은 세종류의 이진 트리를 구현한다고 가정한다.

  1. 일반 TreeNode
  2. 자식을 갖지 않는 TreeNode인 LeafNode
  3. 자식을 갖는 TreeNode인 InnerNode
type TreeNode = {
	value : string
}

type LeafNode = TreeNode & {
	isLeaf : true
} 

type InnerNode = TreeNode & {
	children : [TreeNode] | [TreeNode, TreeNode]
}

TreeNode의 서브타입을 인수로 받아 같은 서브타입을 반환하는 mapNode 함수를 구현한다면 

function mapNode<T extends TreeNode>( // 1
	node : T, // 2
    f : (value : string) => string
) : T { // 3
	return {
    	...node,
        value: f(node.value)
    }
}
  1. T의 상한 경계는 TreeNode다. 즉, T는 TreeNode가 아니면 TreeNode의 서브타입이다.
  2. mapNode는 두 개의 매개변수를 받는데 첫 번째 매개변수는 T 타입의 노드다. node는  TreeNode가 아니면 TreeNode의 서브타입이다.
  3. mapNode는 타입인 T인 값을 반환한다.
  • extends TreeNode를 생략하고 T 타입만을 사용한다면 T 타입의 상한 경계가 없으므로 node.value를 읽는 행위가 안전해지지 않기에 컴파일 타임 에러를 던질 확률이 커진다.
  • T를 아예 사용하지 않고 mapNode를 TreeNode로만 선언한다면 타입 정보가 날아가 모두 TreeNode로만 인식된다.

T extends TreeNode라고 표현함으로써 매핑한 이후에도 입력 노드가 특정 타입(TreeNode, LeafNode, InnerNode)이라는 정보를 보존할 수 있다.

 

여러 제한을 적용한 한정된 다형성

 만약 여러 개의 제한을 둘려면 어떻게 해야 할까? 단순히 인터섹셕으로 제한들을 이어 붙이면 된다.

type HasSides = {numberOfSides : number}
type SidesHaveLength = {sideLength: number}

function logPerimeter< // 1
	Shape extends hasSides & SideshaveLength // 2
>(s: Shape) : Shape { // 3
	console.log(s.numberOfSides * s.sideLength)
    return s
}
  1. logPerimeter는 Shape 타입의 인자 s 한 개를 인수로 받는 함수다.
  2. Shape는 HasSides 타입과 SidesHaveLength 타입을 상속받는 제네릭 타입이다.
  3. logPerimeter는 인수와 타입이 같은 값을 반환한다.

한정된 다형성으로 인수의 개수 정의하기

가변 인수 함수(임의의 개수의 인수를 받는 함수)에서도 한정된 다형성을 사용할 수 있다. 자바스크립트의 내장함수인 call 함수를 예시로 구현해보자

function call (
	f : (...args : unknown[]) => unknown,
    ...args : unknown[]
) : unknown {
	return f (...args)
}

function fill(length : number, value: string) : string[] {
	return Array.from({legnth}, ()=> value)
}

call(fill, 10, 'a') // 'a' 10개를 갖는 배열로 평가

우리가 표현하려는 제한은 다음과 같다.

  • f는 T 타입의 인수를 몇 개 받아서 R 타입을 반환하는 함수다. 인수가 몇 개인지 미리 알 수 없다.
  • call은 f 한 개와 T 몇 개를 인수로 받으며 인수로 받은 T들을 f가 다시 인수로 받는다. 인수가 몇 개인지 미리 알 수 없다.
  • call은 f의 반환 타입과 같은 R 타입을 반환한다.
function call <T extends unknown[], R> ( // 1
	f : (...args : T) => R, // 2
    ...args : T // 3
) : R { // 4
	return f (...args)
}
  1. call 은 가변 인수로 T와 R 두 개의 타입 매개변수를 받는다. T는 unknown[]의 서브타입, 어떤 타입의 배열 또는 튜플이다.
  2. f 또한 가변 인수 함수로, args와 같은 타입의 인수를 받는다.
  3. args의 타입은 T이며 T는 배열 타입이어야 하므로 타입스크립트는 args용으로 전달한 인수를 보고 T에 맞는 튜플 타입을 추론한다.
  4. call은 R타입의 값을 반환한다.

4.2.6 제네릭 타입 기본값

함수 매개변수와 같이 제네릭 타입 매개변수에도 기본 타입을 지정할 수 있다.

type MyEvent<T> = {
	target: T
    type: string
}

새 이벤트를 만들려면 제네릭 타입을 MyEvent로 명시적으로 한정하여 이벤트가 발생한 HTML요소를 정확하게 가리켜야 한다.

let myEvent : MyEvent<HTMLButtonElement> = {
	targer: myButton
    type: string
}

특정 요소 타입을 알 수 없을 때를 대비해 MyEvent의 제네릭 타입에 기본 값을 추가할 수 있다.

type MyEvent< T = HTMLElement> = {
	target: T
    type: string
}

T가 HTML 요소로 한정되도록 T에 경계를 추가할 수도 있다.

type MyEvent< T extends HTMLElement = HTMLElement> = {
	target: T
    type: string
}

특정 HTML 요소 타입에 종속되지 않는 이벤트도 만들 수 있다. 밑 예시 MyEvent의 T를 HTMLElement에 수동으로 한정하지 않아도 된다.

let myEvent : MyEven = {
	target: myElement
    type: string
}

함수의 선택적 매개변수처럼 기본 타입을 갖는 제네릭은 반드시 기본 타입을 갖지 않는 제네릭의 뒤에 위치해야한다.

type MyEvent2<
	Type extends string,
    Target extends HTMLElement = HTMLElemnet
> = {
	target : Target
    type : type
} // GOOD

type MyEvent3<
    Target extends HTMLElement = HTMLElemnet,
	Type extends string
> = {
	target : Target
    type : type
} // 에러 TS2706 : 필수 타입 매개변수는 선택적 타입 매개변수 뒤에 올수 없음

4.3 타입 주도 개발

  • 타입 주도 개발 : 타입 시그니처를 먼저 정하고 값을 나중에 채우는 프로그래밍 방식

표현력이 높은 타입 시스템을 함수에 적용하면 함수 타입 시그니처를 통해 함수에 관하여 원하는 거의 모든 정보를 얻을 수 있다.

function map<T,U> (array : T[], f: (item: T)=> U) : U[] {
	// ...
}

이전에 map을 본 적 없더라도 이 시그니처를 보고 map이 어떤 동작을 하는지 어느 정도는 감을 잡을 수 있을 것이다. 타입스크립트 프로그램을 구현할 때는 먼저 함수의 시그니처를 정의한 다음 구현을 추가한다.

4.4 마치며 

지금까지의 배운 것들을 요약한다면

  • 함수를 선언하고 호출하는 방법
  • 매개변수의 타입을 지정하는 방법
  • 매개변수의 기본값, 나머지 매개변수, 제너레이터 함수
  • 타입스크립트의 반복자
  • 함수의 호출 시그니처와 구현의 차이
  • 문맥적 타입화
  • 함수를 오버로드하는 다양한 방법
  • 함수의 다형성과 타입 별칭
  • 제네릭 타입은 어디에 선언해야하고 어떻게 추론되는지
  • 제네릭 타입에 한계와 기본값을 설정하고 추가하는 방법

연습 문제

1. 타입스크립트는 함수 타입 시그니처에서 어떤 부분을 추론하는가? 매개변수 타입, 반환 타입 또는 두 가지 모두?

- 타입스크립트는 함수의 실제 구현을 포함한 경우에 한해서 매개변수 타입과 반환 타입을 모두 추론할 수 있다. 그러나 함수 타입 시그니처(즉, 함수의 선언에서 구현이 없는 형태)만으로는 타입스크립트가 매개변수 타입과 반환 타입을 추론할 수 없다. 이런 경우에는 반환 타입을 명시해야 한다.

 

2. 자바스크립트의 arguments 객체는 타입 안전성을 제공하는가? 그렇지 않다면 무엇으로 대체할 수 있는가 ?

- arguments 객체는 함수로 전달된 인수들의 타입 정보를 제공하지 않아  타입스크립트의 타입 체크 기능을 사용할 수 없게 만든다. 나머지 매개변수(Rest Parameters)를 사용하여 arguments 객체를 대체할 수 있다. 나머지 매개변수는 함수의 인수들을 배열로 수집하므로, 타입 안전성을 제공하고 배열 메서드를 사용할 수 있다.

 

3.

type Reserve = {
	(from: Date, to: Date, destination: string) : Reservation
   	(from: Date, destination: string) : Reservation
    (destination: string) : : Reservation
}

 

+ Recent posts