타입스크립트에서 함수란

Jin·2022년 3월 1일
0

Typescript

목록 보기
3/4

자바스크립트 (이하 JS)에서 함수는 일급 객체입니다.

  • 함수를 변수에 할당
  • 함수를 다른 함수로 전달
  • 함수에서 함수를 반환
  • 함수를 객체와 프로토타입에 할당
  • 함수에 프로퍼티를 기록
  • 함수에 기록된 프로퍼티 읽기

따라서, 위와 같은 작업을 할 수 있습니다. 타입스크립트 (이하 TS)는 이 모든 것을 타입 시스템에 녹여냈습니다.

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

보통 함수 매개변수의 타입은 명시적으로 정의합니다. TS는 항상 함수의 본문에서 사용된 타입들을 추론하지만 특별한 상황이 아니라면 매개변수 타입은 추론하지 않습니다. 반환 타입은 자동으로 추론하지만 원하면 명시할 수 있습니다.

실무에서는 TS가 반환 타입을 추론하도록 하는 게 일반적이라고 합니다. TS가 해줄 수 있는 일을 개발자가 직접 할 필요가 없기 때문입니다.

JS와 TS는 최소 다섯 가지의 함수 선언 방법을 지원합니다.

function hi(name: string) {
    return "hello " + name;
}

const hi2 = function (name: string) {
    return "hello " + name;
}

const hi3 = (name: string) => {
    return "hello " + name;
}

const hi4 = (name: string) => "hello " + name;

const hi5 = new Function("name", "return 'hello ' + name");

TS는 5번째 방법인 함수 생성자를 제외한 모든 문법을 안전하게 지원합니다. 함수 생성자 방식은 매개변수 타입과 반환 타입을 지정하지 않았으므로 어떤 인수를 건네서도 호출할 수 있기 때문에 안전하지 않습니다.

객체와 튜플 타입과 마찬가지로 함수에서도 ?를 이용하여 선택적 매개변수를 선언할 수 있습니다. 함수의 매개변수를 선언할 때는 반드시 필수 매개변수를 먼저 지정하고 선택적 매개변수를 뒤에 추가하여야 합니다.

function log(message: string, name?: string) {
    console.log(message + " by " + name || "ananymous")
}

또한, JS에서처럼 매개변수에 기본값을 지정할 수 있습니다. 의미상으로는 호출자가 해당 매개변수에 값을 전달하지 않아도 되므로 매개변수를 선택적으로 만드는 것과 같습니다. 선택적 매개변수는 뒤에 와야 하지만 기본 매개변수는 어디에나 위치해도 된다는 점도 다릅니다.

function log(message: string, name = "ananymous") {
    console.log(message + " by " + name)
}

name에 기본값을 제공하므로 선택형 표시와 타입을 지정할 필요가 없어졌습니다. TS는 기본값으로 매개변수의 타입을 추론할 수 있기 때문에 코드가 간결해지고 읽기도 쉬워집니다. 물론 기본 매개변수에도 명시적으로 타입을 선언할 수도 있습니다.

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

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

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

sum([1, 2, 3]); // 6

때로는 고정 인자가 아니라 인수의 개수가 달라질 수 있는 가변 인자가 필요할 때도 있습니다. 전통적으로 JS에서는 arguments 객체를 통해 이 기능을 제공하였습니다. 하지만 arguments는 전혀 안전하지 않다는 치명적인 문제를 안고 있었지만 es6부터는 이를 rest parameter를 통하여 해결하였습니다!

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

sum(1, 2, 3, 4)
sum(1, 2, 3, 4, 5, 6, 7)

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

함수는 최대 1개의 rest parameter를 가질 수 있으며 함수의 매개변수 목록 맨 마지막에 위치하여야 합니다.

this

함수 안에는 저마다의 this가 존재합니다. 하지만, JS 기반 언어 (TS 포함)에서는 의도치 않은 this 바인딩으로 골치를 앓는 경우가 많습니다. 그래서 this를 명시적으로 선언해주는 함수가 존재합니다.

call, apply, bind

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

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

apply는 함수 안에서 값을 this로 한정(bind)하며 두 번째 인수를 펼쳐서 함수에 매개변수로 전달합니다. (여기서는 this를 null로 한정)

call도 같은 기능을 수행하지만 인수를 펼쳐서 전달하지 않고 순서대로 전달한다는 점만 다릅니다.

bind도 call처럼 인수를 순서대로 전달하지만 call과 다른 점은 함수를 호출하지 않고 새로운 함수를 반환합니다. 그래서 개발자는 뒤에 ()나. call을 이용하여 반환된 함수를 호출하여야 실행됩니다.

그렇다면 this를 명시적으로 지정하지 않았을 때는 어떻게 될까요?

만약, 이 글을 읽는 독자가 JS 입문 단계이거나 한 번도 사용하지 않았다면 JS 기반 언어에서 this가 모든 함수에서 정의된다는 사실에 놀랄 수도 있을 것 같습니다. this의 값은 함수를 어떻게 선언하느냐가 아닌 어떻게 호출했는지에 따라 달라지는데 이는 JS 코드를 이해하기 어렵게 만드는 고질적인 문제 중 하나입니다.

const x = {
    a() {
        return this;
    }
}

x.a(); // this는 X

const b = x.a;
b(); // this는 정의되지 않은 상태

메서드를 호출할 때 this는 점 왼쪽의 값을 갖는다는 것이 일반적인 원칙입니다. 하지만 위의 코드처럼 같은 동작을 수행함에도 함수 내부의 this는 완전히 달라질 수 있습니다. 이처럼 this의 동작은 예상과 다를 수 있습니다. 한 가지 분명한 것은 함수를 어떻게 호출하느냐에 큰 영향을 받는다는 것입니다.

TS는 이 문제를 잘 처리해줍니다.

함수에서 this를 사용할 때는 항상 기대하는 this 타입을 함수의 첫 번째 매개변수로 선언하는 습관을 들여야 합니다. 그러면 함수 안에 등장하는 모든 this가 우리가 의도한 this임을 TS가 보장해줍니다.

function when(this: Date) {
    return `${this.getDate()} / ${this.getMonth()} / ${this.getFullYear()}`;
}

when.apply(new Date);
when(); // error

제너레이터

제너레이터의 설명은 https://velog.io/@dkdlel102/자바스크립트에서-제너레이터와-asyncawait 에 잘 되어 있습니다.

여기서는 TS를 사용하면서 가지게 되는 타입과 관련된 부분을 추가하는 식으로 설명하겠습니다.

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

const fibonacciGenerator = createFibonacci(); // IterableIterator<number> 타입
fibonacciGenerator.next(); // {value: 0, done: false}
fibonacciGenerator.next(); // {value: 1, done: false}
fibonacciGenerator.next(); // {value: 2, done: false}
fibonacciGenerator.next(); // {value: 3, done: false}
fibonacciGenerator.next(); // {value: 5, done: false}

TS에서 제너레이터의 타입은 기본적으로 IterableIterator입니다. TS는 거기에다 사용되는 구체 타입이 지정되어야 하는데 명시할 수도 있지만 그렇지 않은 경우에는 추론합니다. 여기서는 number로 추론한 경우입니다.

호출 시그니처

함수의 전체 타입을 표현하는 방법을 알아보겠습니다.

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

여기서 sum 함수는 무슨 타입일까요?

sum은 함수이므로 sum의 타입은 Function이 될 것입니다. 하지만 이것으로는 뭔가 부족합니다. obejct가 모든 객체를 가리킬 수 있는 것처럼 Function은 모든 함수의 타입을 뜻할 뿌이며 그것이 가리키는 특정 함수와 타입에 관련된 정보는 아무것도 알려주지 않습니다.

sum은 두 개의 number를 인수로 받아 한 개의 number를 반환하는 함수이므로

(a: number, b: number) => number와 같이 표현할 수 있습니다.

이 코드는 TS의 함수 타입 문법으로, 호출 시그니처 혹은 타입 시그니처라고 부릅니다.

함수 호출 시그니처는 타입 수준 코드, 즉 값이 아닌 타입 정보만 포함됩니다. 바디를 포함하지 않기에 타입을 추론할 수 없으므로 반환 타입을 반드시 명시하여야 합니다.

타입 수준 코드는 타입과 타입 연산자를 포함하는 코드입니다. 값 수준 코드는 그 밖의 모든 것을 가리킵니다.

function area(radius: number): number | null {
    if (radius < 0) {
        return null;
    }

    return Math.PI * (radius ** 2);
}

let r: number = 2;
let a = area(r);
if (a !== null) {
    console.info(`result: ${a}`);
}

위의 코드에서 타입 수준의 코드는 number, number | null 부분뿐이고 나머지는 전부 값 수준의 코드입니다.

다시 호출 시그니처로 돌아와서,

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

const log: Log = (message, name = "ananymous") => {
    let time = new Date().toISOString();
    console.log(time, message, name);
}

위의 코드에서 알 수 있는 것은 크게 4가지입니다.

  • 함수 표현식 log를 선언하면서 Log 타입임을 명시
  • Log에서 message의 타입을 string으로 이미 명시했으므로 다시 지정할 필요 X
  • name에 기본값 지정
  • Log 타입에서 반환 타입을 void로 지정했으므로 다시 지정할 필요 X

위의 코드는 함수의 매개변수 타입을 명시하지 않아도 되는 첫 사례였습니다. 이는 문맥적 타입화라는 TS의 강력한 타입 추론 기능 덕분입니다.

호출 시그니처의 표현 방식에는 단축형과 전체가 있습니다.

type Log = (message: string, name?: string) => void;
type Log = {
    (message: string, name?: string) => void
}

위가 단축형, 아래가 전체 호출 시그니처입니다. 두 코드는 문법만 조금 다를 뿐 모든 면에서 같습니다.

Log 함수처럼 간단한 상황이라면 단축형을 주로 활용하는 것이 좋고 더 복잡한 함수라면 전체 시그니처를 사용하는 것이 좋을 때가 있습니다. 그 대표적인 경우가 바로 함수 타입의 오버로딩을 활용해야 하는 경우입니다.

대부분의 프로그래밍 언어에서는 항상 일정 수의 매개변수를 건네면 항상 똑같은 타입의 반환값을 받게 됩니다. JS 기반 언어는 예외입니다. 동적 언어이기 때문에 인수 타입과 반환 타입이 달라질 때도 있습니다. 오버로딩된 함수는 달라지는 경우의 수를 명시함으로써 함수의 타입을 한정시켜 줍니다.

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

Reserve라는 타입은 2가지 경우의 수를 커버할 수 있습니다. 왕복 예약, 편도 예약이 그것입니다. 쉽게 예상할 수 있다시피 from은 출발시간, to는 돌아가는 시간인데 이것이 없는 예약이 편도 예약이 되는 것이기 때문입니다.

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

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

위의 코드처럼 구현의 시그니처는 오버로드 시그니처를 수동을 결합한 결과와 같습니다 (signature1 | signature2)

그러므로 각각의 시그니처가 동작할 수 있도록 지원하여야 합니다. 그러므로 구현 함수의 매개변수 타입이 달라지는 것입니다.

오버로딩 시그니처는 가능한 구체적인 것이 좋습니다.

다형성

  • string
  • number
  • boolean
  • Date[]
  • string | null
  • {a: number} | {b: number}
  • (...numbers: number[]) => number

위와 같은 타입들은 모두 구체 타입입니다.

기대하는 타입을 정확하게 알고 있고, 실제 이 타입이 전달되었는지 확인할 때는 구체 타입이 유용합니다. 하지만 때로는 어떤 타입을 사용할지 미리 알 수 없는 상황이 있는데 이런 상황에서는 함수를 특정 타입으로 제한하기는 어렵습니다.

내장 메서드인 filter를 예로 들어보겠습니다.

type Filter = {
    (array: unknown, f: unknown) => unknown[]
}

타입을 모르기 때문에 모든 타입이 unknown입니다. 만약에 number 타입이라고 가정하면 unknown 대신 number로 타입 부분이 모두 채워졌을 것입니다.

하지만, 타입의 종류는 많습니다.

그것들을 모두 오버로딩 함수로 표현하는 것도 코드가 많아지는 것만 감수하면 동작에는 지장이 없어 보이지만 실은 그렇지 않습니다.

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

object는 객체의 실제 형태에 대해서는 어떤 정보도 알려주지 않습니다. 따라서 배열에 저장된 객체의 프로퍼티에 접근하려고 시도하면 TS가 에러를 발생시킵니다.

여기서 제네릭 타입이 등장합니다.

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

제네릭 타입이 강력한 이유는 제네릭 타입으로 정의된 함수를 호출한 시점에서 TS가 T의 타입을 추론해내면 그 함수에 정의된 모든 T를 추론한 타입으로 대체한다는 것입니다.

보통, T를 제네릭 타입 매개변수로 사용하며 꺾쇠괄호 (<>)로 제네릭 타입 매개변수임을 선언합니다. TS는 지정된 영역에 속하는 모든 제네릭 타입 매개변수 인스턴스가 한 개의 구체 타입으로 한정되도록 보장합니다. 전달된 인수의 타입을 통해 어떤 타입으로 한정할지 추론하는 것입니다.

제네릭은 함수의 기능을 더 일반화하여 설명할 수 있는 강력한 도구이므로 가능하면 제네릭을 사용하는 것이 좋습니다.

T의 범위를 타입 별칭으로 한정하려면 타입을 명시하면 됩니다.

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

let filter: Filter<number> = (array, f) => {
    ...
}

이런 식으로 명시할 수도 있습니다. 제네릭 선언도 마찬가지로 단축형과 전체 시그니처 표현식 모두 사용 가능합니다.

제네릭 타입도 기본값을 설정할 수 있습니다.

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

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

TS가 강력한 이유 중 하나는 호출 시그니처만 봐도 어떤 동작을 하는지 어느 정도 감을 잡을 수 있다는 것입니다.

TS 프로그램을 구현할 때는 먼저 함수의 타입 시그니처를 정의한 다음 구현을 추가하는 식으로 코딩 순서 습관을 들인다면 TS의 타입 시스템이 이끄는 안전한 프로그램 구현이 훨씬 쉬워지므로 권장되는 방법입니다.

profile
배워서 공유하기

0개의 댓글