본 글은 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} 로 평가
- 함수명 앞의 *는 함수는 제네레이터임을 의미한다. 호출하면 이터러블 반복자가 반환된다.
- 영구적으로 값을 생성할 수 있다.
- yield라는 키워드로 값을 생성할 수 있다. 다음 값을 요청하면 (예 : next 호출), yield를 이용해 결과를 소비자에게 보내고, 다음 값을 다시 요청하기 전까지는 실행을 중지한다. 이런 방식으로 동작하므로 무한 루프에 걸릴 가능성이 존재하지 않는다.
- 피보나치 숫자를 계산하기 위해 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)
}
- 함수 표현식 log를 선언하면서 log 타입임을 명시했다.
- Log에서 message의 타입을 string으로 이미 명시했으므로 매개변수의 타입을 다시 지정할 필요는 없다.
- userId에 기본값을 지정한다. 호출 시그니처는 값을 포함할 수 없으므로 Log에서는 userId의 타입은 지정할 수 있지만 기본값은 지정할 수 없기 때문이다.
- 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
// ...
}
- 오버로드된 함수 시그니처 두 개를 선언한다.
- 구현의 시그니처는 두 개의 오버로드 시그니처를 수동으로 결합한 결과와 같다. (즉, 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
// ...
}
- 매개변수는 문자열 리터럴 타입으로 오버로드했다.
- 오버로드에 지정되지 않은 문자열을 createElement로 전달하면 타입스크립트는 이를 HTMLElement로 분류한다.
- 구현의 매개변수는 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
// ...
}
- T의 범위를 개별 시그니처로한정한 전체 호출 시그니처. 각각의 filter 호출은 자신만의 T 한정 값을 갖는다.
- T의 범위를 모든 시그니처로 한정한 전체 호출 시그니처. 타입스크립트는 Filter 타입의 함수를 선언할 떄 한정한다.
- 1과 비슷하지만 전체 시그니처가 아닌 단축 호출 시그니처 문법.
- 2과 비슷하지만 전체 시그니처가 아닌 단축 호출 시그니처 문법.
- 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'
})
- 객체에 triggerEvent를 호출한다.
- 함수 시그니처를 통해 MyEvent<T>타입임을 파악된다. 이 타입이 {target: T, type: string}으로 정의 됐다는 것도 파악된다.
- 호출자가 전달한 객체의 target 필드가 document.querySelector('#myButton')임이 파악된다.
- T의 타입이 document.querySelector('#myButton') 이며 이는 Element | null 타입으로 한정된다.
- 타입스크립트가 T를 Element | null로 대체한다.
4.2.5 한정된 다형성
때로는 U타입은 적어도 T타입을 포함하는 기능이 필요하다. 이런 상황을 U가 T의 상한 한계라고 설명한다. 예는 다음과 같은 세종류의 이진 트리를 구현한다고 가정한다.
- 일반 TreeNode
- 자식을 갖지 않는 TreeNode인 LeafNode
- 자식을 갖는 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)
}
}
- T의 상한 경계는 TreeNode다. 즉, T는 TreeNode가 아니면 TreeNode의 서브타입이다.
- mapNode는 두 개의 매개변수를 받는데 첫 번째 매개변수는 T 타입의 노드다. node는 TreeNode가 아니면 TreeNode의 서브타입이다.
- 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
}
- logPerimeter는 Shape 타입의 인자 s 한 개를 인수로 받는 함수다.
- Shape는 HasSides 타입과 SidesHaveLength 타입을 상속받는 제네릭 타입이다.
- 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)
}
- call 은 가변 인수로 T와 R 두 개의 타입 매개변수를 받는다. T는 unknown[]의 서브타입, 어떤 타입의 배열 또는 튜플이다.
- f 또한 가변 인수 함수로, args와 같은 타입의 인수를 받는다.
- args의 타입은 T이며 T는 배열 타입이어야 하므로 타입스크립트는 args용으로 전달한 인수를 보고 T에 맞는 튜플 타입을 추론한다.
- 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
}