본 포스팅은 "타입스크립트 프로그래밍" 책을 읽고 정리한 내용입니다.

이번 장에서 살펴볼 주제는 다음과 같습니다.

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

함수 선언과 호출

자바스크립트에서 함수는 일급 객체입니다. 객체를 다루듯 함수를 변수에 할당하거나, 함수를 다른 함수로 전달하거나, 함수에서 함수를 반환하거나, 객체와 프로토타입에 할당하거나, 함수에 프로퍼티를 기록하거나, 함수에 기록된 프로퍼티를 읽는 등의 작업을 할 수 있습니다.

타입스크립트는 이 모든 것을 자신의 풍부한 타입 시스템에 녹여냈습니다. 다음은 타입스크립트 함수의 예시입니다.

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

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

반환 타입은 자동적으로 추론하지만 원하면 명시할 수 있습니다.

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) => 'hello ' + name

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

타입스크립트는 함수 생성자(안전하지 않으므로 사용하지 않는게 좋음)를 제외한 모든 문법을 안전하게 지원하며, 모든 문법은 보통 매개변수 타입의 필수 어노테이셔느 반환 타입의 선택형 어노테이션에 적용하는 것과 같은 규칙을 따릅니다.

타입스크립트에서 함수를 호출할 때 타입 정보는 따로 제공할 필요가 없으며, 바로 인수를 전달하면 타입스크립트가 함수의 매개변수와 인수의 타입이 호환되는지 확인합니다.

add(1, 2) // 3
greet('Crystal') // 'hello Crystal'

물론 인수를 전달하지 않거나 잘못된 타입의 인수를 전달하면 타입스크립트가 에러를 발생시킵니다.

add(1)
add(1, 'a')
// -> 에러

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

객체와 튜플 타입에서처럼 함수에서도 ?를 이용해 선택적 매개변수를 지정할 수 있습니다. 함수의 매개변수를 선언할 때 필수 매개변수를 먼저 지정하고 선택적 매개변수를 뒤에 추가합니다.

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

log('Page loaded')
log('User signed in', 'da763be')

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

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

log('User clicked on a button', 'da763be')
log('User signed out')

UserId에 기본 값을 제공하므로 선택형 마크(?)를 지정할 필요가 없어졌습니다. 영리한 타입스크립트는 기본값으로 매개변수의 타입을 추론할 수 있기 때문입니다. 덕분에 코드가 간결해지고 읽기도 쉬워졌습니다. 물론 일반 매개변수에 타입을 지정하듯 기본 매개변수에도 타입을 명시할 수 있습니다.

type Context = {
  addId?: string
  userId?: string
}

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

보통 실무에서는 선택적 매개변수보다 기본 매개변수를 더 자주 사용합니다.

나머지 매개변수

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

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

sum([1,2,3])

때로는 고정 인자 API가 아니라 가변 인자 API가 필요할 때도 있습니다. 전통적으로 자바스크립트는 arguments 객체를 통해 이 기능을 제공했죠.

하지만 이는 한 가지 큰 문제가 있습니다. 전혀 안전하지 않다는 것이죠.

그렇다면 어떻게 안전한 타입의 가변 인수 함수를 만들 수 있을까요? 바로 나머지 매개변수로 이 문제를 해결할 수 있습니다.

안전하지 않은 arguments를 사용하는 대신 나머지 매개변수를 이용해 sum함수가 안전하게 임의의 인수를 받게 만드는 것이죠.

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

sumVariadicSafe(1,2,3)

기존 함수와 달라진 부분은 sum 함수의 매개변수 목록에 ...이 추가되었다는 점뿐이지만 덕분에 타입 안전성을 갖춘 함수가 만들어졌습니다.

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

interface Console {
  log(message?: any, ...optionalParams: any[]): void
}

call, apply, bind

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

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

add(10, 20)
add.apply(null, [10,20])
add.call(null, 10, 20)
add.bind(null, 10, 20)()

TSC 플래그: strictBindCallApply
코드에서 .call, .apply, .bind를 안전하게 사용하려면 tsconfig.json에서 strictBindCallApply를 활성화해야 합니다. (strict 모드를 활성화 했다면 이 옵션은 자동으로 활성화 됨)

this의 타입

this의 값은 함수를 어떻게 호출했는지에 따라 달라지는데 이는 자바스크립트 코드를 이해하기 어렵게 만드는 고질적인 문제 중 하나입니다.

많은 개발팀은 클래스 메서드를 제외한 다른 모든 곳에서 this 사용을 금지합니다. TSLint 규칙에서 no-invalid-this를 활성화하면 여러분 코드에 이런 this가 침투하는 일을 방지할 수 있습니다.

다행히 타입스크립트는 이 문제를 잘 처리합니다. 함수에서 this를 사용할 때는 항상 여러분이 기대하는 this 타입을 함수의 첫 번째 매개변수로 선언합시다. 그러면 함수 안에 등장하는 모든 this가 여러분이 의도한 this가 됨을 타입스크립트가 보장합니다. 함수 시그니처에 사용한 this는 예약어이므로 다른 매개변수와 완전히 다른 방식으로 처리됩니다.

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

이렇게 수정한 fancyDate을 호출하면 다음과 같은 일이 벌어집니다.

fancyDate(new Date) // 성공
fancyDate() // 에러

타입스크립트에 많은 정보를 제공한 덕분에 런타임 에러 대신 컴파일 타임에 경고를 시작합니다.

TSC 플래그: noImplicitThis
tsconfig.json에서 noImplicitThis를 활성화하면 함수에서 항상 this 타입을 명시적으로 설정하도록 강제할 수 있습니다. 단, noImplicitThis는 클래스와 객체의 함수에는 this를 지정하라고 강제하지 않습니다.

제너레이터 함수

제너레이터 함수는 여러 개의 값을 생성하는 편리한 기능을 제공합니다. 제너레이터 함수를 이용하면 값을 생산하는 속도돋 정교하게 조절할 수 있습니다. 제너레이터 함수는 게으르게 동작하기 때문에 무한의 목록 생성하기 같은 까다로운 기능을 제공할 수 있습니다.

function* createFibonacciGenerator() {
  let a = 0
  let b = 1
  while (true) {
    yield a;
    [a, b] = [b, a+b]
  }
}

createFibonacciGenerator 함수는 IterableIterator를 반환하고, 이 제너레이터에 next를 호출할 때마다 다음 피보나치 값을 계산해서 결과를 방출합니다. 타입스크립트가 방출된 값의 타입을 이용해 반복자의 타입을 추론함을 알 수 있습니다.

다음 예처럼 IerableIterator에서 방출하는 타입을 감싸서 제너레이터의 타입을 명시하는 방법도 있습니다.

function* createNumbers(): IterableIterator<number> {
  let n = 0
  while (1) {
    yield n++
  }
}

let numbers = createNumbers()
numbers.next()

요약하면 제너레이터는 자바스크립트의 정말 멋진 기능이며 타입스크립트에서도 지원한다는 사실입니다.

호출 시그니처

지금까지 함수의 매개변수 타입과 반환 타입을 살펴봤습니다. 이번에는 하수의 전체 타입을 표현하는 방법을 확인해보겠습니다.

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

sum은 무슨 타입일까요? 함수이므로 Function 타입이라고 할 수 있습니다. 하지만 이는 아무런 정보도 주지 않기에 즐겨 사용하는 타입이 아니죠.

sum의 타입을 표현할 수 있는 다른 방법이 있을까요? 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) => void

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

함수의 호출 시그니처는 구현코드와 거의 같습니다. 우연이 아니라 언어 설계상 의도한 결정으로, 이렇게 함으로 호출 시그니처를 쉽게 추론할 수 있습니다.

호출 시그니처를 함수 표현식과 합치는 것 역시 가능합니다. 기존의 Log 함수를 새로운 시그니처에 맞게 다시 구현해봅시다.

type Log = (message: string, userId?: string) => void

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

문맥적 타입화

마지막 예는 함수의 매개변수 타입을 명시하지 않아도 되는 첫 사례였습니다. 이미 log의 타입을 Log로 지정했으므로 타입스크립트가 message의 타입을 string으로 추론할 수 있기 때문입니다. 이는 문맥적 타입화라는 타입스크립트의 강력한 타입 추론 기능입니다.

times라는 함수를 다음과 같이 선언해보겠습니다.

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) {
  console.log(n)
}

times(f, 4) // 에러

오버로드된 함수 타입

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

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

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

두 코드는 문법만 다를 뿐 모든 면에서 같습니다. Log 함수처럼 간단한 상황이라면 단축형을 주로 활용하되 복잡한 함수라면 전체 시그니처를 사용하는 것이 좋을 때도 있습니다.

함수 타입의 오버로딩이 좋은 예입니다.

타입스크립트는 동적 특징을 오버로드된 함수 선언으로 제공하고, 입력 타입에 따라 달라지는 함수의 출력 타입은 정적 타입 시스템으로 각각 제공합니다.

Reserve라는 휴가 예약 API를 설계한다고 가정해봅시다. 먼저 타입을 다음처럼 지정할 수 있습니다.

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

이를 구현보겠습니다.

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

발리로 여행을 가려는 고객이 있다면 from과 to에는 날짜를, destination은 "Bali"로 설정해 reserve API를 이용할 것입니다.

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

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

이 코드를 실행하려 하면 타입스크립트가 Reserve를 구현한 코드에서 에러를 발생시킵니다.

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

위의 Reserve 예제에서는 reserve 함수를 다음처럼 바꿀 수 있습니다.

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

let reserve: Reserve = (
  from: Date,
  toOrDestination: Date | string,
  destination?: string
) => { // (2)
  // ...
}
(1) 오버로드된 함수 시그니처 두 개를 선언한다.
(2) 구현의 시그니처는 두 개의 오버로드 시그니처를 수동으로 결합한 결과와 같다. 결합된 시그니처는 reserve를 호출하는 함수에는 보이지 않는다.

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

let reserve: Reserve = (
  from: Date,
  toOrDestination: Date | string,
  destination?: string
) => {
  if (toOrDestination instanceof Date && destination !== undefined) {
    // 편도 여행
  } else if (typeof toOrDestination === 'string') {
    // 왕복 여행 예약
  }
}

오버로드는 자연스럽게 브라우저 DOM API에서 유용하게 활용됩니다. 새로운 HTML 요소를 만들 때 사용하는 createElement DOM API를 살펴보겠습니다.

createElement의 타입은 다음과 같이 지정할 수 있습니다.

type CreateElement = {
  (tag: 'a'): HTMLAnchorElement
  (tag: 'canvas'): HTMLCanvasElement
  (tag: 'table'): HTMLTableElement
  (tag: string): HTMLElement
}
let createElement: CreateElement = (tag: string): HTMLElement => {
   // ...
}

지금까지 함수 표현식을 오버로드 하는 방법을 살펴봤습니다. 하지만 함수 선언을 오버로드 하는 방법도 존재합니다. createElement 오버로드의 예를 다음처럼 구현할 수 있습니다.

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)
}
warnUser.wasCalled = false

사용자에게 경고를 한 번만 보여주는 함수를 만들었습니다. 이 함수의 전체 시그니처를 타입스크립트로 표현해보겠습니다.

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

warnUser는 호출할 수 있는 함수인 동시에 불 속성인 wasCalled도 가지고 있습니다.

<출처>

  • 타입스크립트 프로그래밍, 보리스 체르니, OREILLY
profile
웹 개발을 공부하고 있는 윤석주입니다.

2개의 댓글

comment-user-thumbnail
2022년 7월 15일

따봉~

1개의 답글