9 분 소요

함수 타입

함수의 타입을 설명한다는 것은 어떤 함수가 각각 어떤 타입의 매개 변수를 몇 개 받아서, 어떤 값을 반환하는 지를 이야기 하는 것이다.

따라서 함수의 타입은 다음과 같이 매개 변수와 반환값의 타입으로 결정된다.

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

참고로 함수의 반환값 타입은 자동으로 추론되기 때문에 복잡하지 않은 경우에는 다음과 같이 생략해도 무방하다.

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

화살표 함수 역시 타입 정의 방식은 동일하다.

const add = (a: number, b: number): number => a + b;

매개변수 설정

매개변수 기본값

매개 변수에 기본값이 설정되어 있으면 타입이 자동으로 추론되므로 타입 정의를 생략해도 된다.

function introduce(name = "주노정") {
  console.log(`name : ${name}`);
}

이때 당연히 기본값과 다른 타입으로 매개변수의 타입을 정의하면 오류가 발생한다.

function introduce(name: number = "주노정숫자") {
  // 오류 발생
  console.log(`name : ${name}`);
}

당연히 기본값과 다른 타입의 값을 인수로 전달해도 오류가 발생한다.

function introduce(name = "주노정") {
  console.log(`name : ${name}`);
}

introduce(1); // 오류

선택적 매개변수

다음과 같이 매개변수 뒤에 물음표(?)를 붙여주면 선택적 매개변수가 되어 함수 호출 시 생략이 가능하게 된다.

function introduce(name = "주노정", tall?: number) {
  console.log(`name : ${name}`);
  console.log(`tall : ${tall}`);
}

introduce("주노정", 184);
introduce("주노정"); // 생략 가능

위 코드의 tall과 같은 선택적 매개변수의 타입은 자동으로 해당 타입과 undefined의 유니온 타입(기본값 타입 | undefined)으로 추론된다. 따라서 tall의 타입은 현재 number | undefined가 된다. 그러므로 이 값이 number 타입의 값이라고 확신하려면 타입 가드를 통한 타입 좁히기가 필요하다.

function introduce(name = "이정환", tall?: number) {
  console.log(`name : ${name}`);
  if (typeof tall === "number") {
    console.log(`tall : ${tall + 10}`);
  }
}

한 가지 주의할 점은 선택적 매개변수는 필수 매개변수 앞에 올 수 없으며 반드시 뒤에 배치해야 한다.

function introduce(name = "홍길동", tall?: number, age: number) {...} // 오류
function introduce(name = "홍길동", age: number, tall?: number) {...} // OK

나머지 매개변수

자바스크립트의 rest 매개변수는 스프레드 연산자처럼 ...로 표기하며, 각각의 개별 요소들을 배열로 묶는 역할을 한다. 아래의 자바스크립트 예시 코드를 보고 이해하면 쉽다.

function func(param, ...rest) {
  console.log(param);
  console.log(rest);
}

func(1, 2, 3, 4);
// 1
// [2, 3, 4]

다음과 같이 여러 개의 숫자를 인수로 받는 함수가 있다고 가정하자.

function getSum(...rest) {
  let sum = 0;
  rest.forEach((it) => (sum += it));
  return sum;
}

getSum함수는 나머지 매개변수 rest로 배열 형태의 number 타입의 인수들을 담은 배열을 전달받는다. 이때 rest 파라미터의 타입은 다음과 같이 정의할 수 있다.

function getSum(...rest: number[]) {
  let sum = 0;
  rest.forEach((it) => (sum += it));
  return sum;
}

만약 나머지 매개변수의 길이를 고정하고 싶다면 다음과 같이 튜플 타입을 이용할 수도 있다.

function getSum(...rest: [number, number, number]) {
  let sum = 0;
  rest.forEach((it) => (sum += it));
  return sum;
}

getSum(1, 2, 3); // ✅
getSum(1, 2, 3, 4); // ❌

함수 타입 표현식

다음과 같이 함수 타입을 타입 별칭과 함께 별도로 정의할 수 있다. 이를 함수 타입 표현식(Function Type Expression)이라고 한다.

type Add = (a: number, b: number) => number;
const add: Add = (a, b) => a + b;

이렇게 함수 타입 표현식을 이용하면 함수 선언 및 구현 코드와 타임 선언을 분리할 수 있어 유용하다.

함수 타입 표현식은 다음과 같이 여러개의 함수가 동일한 매개변수와 반환값의 타입을 갖는 경우에 요긴하게 사용할 수 있다.

const add = (a: number, b: number) => a + b;
const sub = (a: number, b: number) => a - b;
const multiply = (a: number, b: number) => a * b;
const divide = (a: number, b: number) => a / b;

다음과 같이 하나의 Operation 함수 타입 표현식을 만들어 돌려서 사용하면 모든 함수에 동일하게 사용할 수 있으며 나중에 동일한 타입의 함수가 또 추가되어도 타입 주석을 이용해 타입 정의만 해주면 되어 매우 편리하다.

type Operation = (a: number, b: number) => number;

const add: Operation = (a, b) => a + b;
const sub: Operation = (a, b) => a - b;
const multiply: Operation = (a, b) => a * b;
const divide: Operation = (a, b) => a / b;

함수 타입 표현식은 반드시 타입 별칭과 함께 사용되어야 하는 것은 아니고 다음과 같이 그냥 함수 타입 표현식을 바로 타입 주석에 사용해도 문제는 없다.

const add: (a: number, b: number) => number = (a, b) => a + b;

호출 시그니처

호출 시그니처 (Call Signature)는 함수 타입 표현식과 동일하게 함수의 타입을 별도로 정의하는 방식이다.

자바스크립트에서는 함수도 곧 객체이기 때문에, 다음과 같이 객체를 정의하듯 함수의 타입을 정의할 수 있다.

type Operation2 = {
  (a: number, b: number): number; // => 가 아니라 :임에 유의
};

const add2: Operation2 = (a, b) => a + b;
const sub2: Operation2 = (a, b) => a - b;
const multiply2: Operation2 = (a, b) => a * b;
const divide2: Operation2 = (a, b) => a / b;

호출 시그니처에선 다음과 같이 함수와 함께 프로퍼티를 추가로 정의하는 것도 가능한데, 이렇게 할 경우 함수이자 일반 객체를 의미하는 타입으로 정의되며 이를 하이브리드 타입이라고 부른다.

type Operation2 = {
  (a: number, b: number): number;
  name: string;
};

const add2: Operation2 = (a, b) => a + b;
(...)

add2(1, 2);
add.name;
이렇게 사용하기에는 다소 혼란스러운 문법이긴 하다

함수 타입의 호환성

함수 타입의 호환성이란 특정 함수 타입을 다른 함수 타입으로 취급해도 괜찮은 지 판단하는 것을 의미하며, 다음의 2가지 기준으로 판단한다.

  • 두 함수의 반환값 타입이 호환되는가?
  • 두 함수의 매개변수 타입이 호환되는가?

반환값 타입의 호환성

A와 B 함수 타입이 있다고 가정할 때, A의 반환값 타입이 B의 반환값 타입의 슈퍼타입이라면 두 타입은 호환된다. 단, 기존에 배웠듯 업캐스팅은 가능하나 다운캐스팅은 불가능하다.

type A = () => number;
type B = () => 10;

let a: A = () => 10;
let b: B = () => 10;

a = b; // ✅ number <- number literal
b = a; // ❌ number literal <- number

매개변수의 타입이 호환되는가?

매개변수의 타입 호환성을 판단할 때에는 두 함수의 매개변수의 개수가 같은지 다른지에 따라 두 가지 유형으로 나뉘게 된다.

매개변수의 개수가 같을 때

두 함수 타입 C, D가 있다고 가정할 때 두 타입의 매개변수의 개수가 같다면, C 매개변수의 타입이 D 매개변수의 타입의 서브타입일 때 호환된다.

type C = (value: number) => void;
type D = (value: 10) => void;

let c: C = (value) => {};
let d: D = (value) => {};

c = d; // ❌
d = c; // ✅

코드로 살펴보았을 때, C 함수타입은 number 타입을 매개변수로 받고, D 함수타입은 number literal 타입을 매개변수로 받는다. 그렇다면 D 타입의 함수를 C 타입의 함수에 할당할 경우 매개변수끼리만 비교해보면 서브타입이 슈퍼타입에 할당되는 업캐스팅에 해당하는 것 같은데, 왜 안된다고 하는 걸까? 또한 가능한 경우를 살펴보면 반환값 타입을 비교할 때와 다르게 마치 다운캐스팅을 허용하는 것 같아 보인다.

이렇게 되는 이유는 두 함수의 매개변수의 타입이 모두 객체 타입일 때 좀 더 두드러진다.

type Animal = {
  name: string;
};

type Dog = {
  name: string;
  color: string;
};

let animalFunc = (animal: Animal) => {
  console.log(animal.name);
};

let dogFunc = (dog: Dog) => {
  console.log(dog.name);
  console.log(dog.color);
};

animalFunc = dogFunc; // ❌
dogFunc = animalFunc; // ✅

위 코드에서는 AnimalDog의 슈퍼타입임을 알 수 있다. 그리고 함수 animalFunc의 매개변수는 Animal 타입이고, dogFunc의 매개변수는 그 서브타입인 Dog 타입이다. 좀 더 자세한 비교를 위해 animalFunc = dogFuncdogFunc = animalFunc를 코드로 표현해보자.

let animalFunc = (animal: Animal) => {
  console.log(animal.name); // ✅
  console.log(animal.color); // ❌
};

let dogFunc = (dog: Dog) => {
  console.log(dog.name); // ✅
};

이제는 눈치 챘을 수도 있지만, name 프로퍼티만 갖고있는 Animal 타입에 비해 animal의 서브타입인 Dog 타입은 추가로 color 프로퍼티도 갖고있다. 따라서 Dog 타입을 매개변수로 받는 함수의 경우 당연하게도 위 코드처럼 Dog 타입의 모든 프로퍼티를 사용할 수 있어야 한다. 그렇다면 이 함수를 매개변수가 슈퍼타입 Animal인 함수에 할당하면 어떻게 될까. 당연히 기존에 함수 내부에서 당연하게 사용했었던 프로퍼티인 color 프로퍼티가 항상 존재할 수는 없는 상황이 되어 오류를 나타낼 것이다.

이를 코드 차원에서 좀 더 기억하기 쉽게 설명하자면, 할당받는 함수(= 좌측)는 들어오는 매개변수의 범위를 고정하고, 할당하는 함수(= 우측)는 함수의 내부 명령문들을 고정하는 것이다. 이를 활용하여 간단한 퀴즈를 내보겠다.

유령이 죽은 날짜와 죽은 이유 프로퍼티가 포함된 사람의 서브타입이라고 가정하면, 사람이 방문하는 일반 호텔에 유령을 다루는 유령 호텔 델루나의 서비스를 제공하면 살아있는 사람이 들어올 경우 아직 죽지 않아 죽은 이유도, 죽은 날짜도 모르니까 성불이고 나발이고 퇴장당할 것이다. 즉 오류이다. 하지만 유령이 방문하는 호텔 델루나에 그냥 일반 호텔의 서비스를 제공하면 유령들은 그냥 미지근한 이승의 서비스만 받는 상황이기 때문에 오류는 발생하지 않는다. 이를 읽고 아래의 코드 블럭에서 어떤 라인이 오류를 나타내는지 생각해보자.

hotelDeLuna = realHotel;
realHotel = hotelDeLuna;
정답 hotelDeLuna는 매개변수로 사람의 서브타입 유령을, realHotel은 유령의 슈퍼타입 사람을 매개변수로 받기 때문에 사람 매개변수가 유령의 서비스를 받는 2번 줄이 오류이다.

매개변수의 개수가 다를 때

매개변수의 개수가 다를 때는 같을 때와 비교적 간단하다. 더 적은 종류의 매개변수를 가진 함수가 많은 매개변수를 가진 함수에 할당할 수 있다.

type Func1 = (a: number, b: number) => void;
type Func2 = (a: number) => void;

let func1: Func1 = (a, b) => {};
let func2: Func2 = (a) => {};

func1 = func2; // ✅
func2 = func1; // ❌

함수 오버로딩

함수 오버로딩이란, 하나의 함수를 매개변수의 개수나 타입에 따라 다르게 동작하도록 만드는 문법이다. 예를 들어, 매개변수가 1개일 때에는 매개변수에 20을 곱한 값을 출력하도록 하고 매개변수가 3개일 때에는 모든 매개변수를 더한 값을 출력하도록 할 수 있다.

타입스크립트에서 함수 오버로딩을 구현하기 위해선 먼저 다음과 같이 오버로드 시그니처를 만들어 줘야 한다.

// 오버로드 시그니처
function func(a: number): void;
function func(a: number, b: number, c: number): void;

이렇게 구현부 없이 선언부만 만들어둔 함수를 오버로드 시그니처라고 한다. 위 코드에서는 2개의 오버로드 시그니처를 만들었으며 각각은 함수의 버전을 의미한다. 위 func 함수는 매개변수를 1개 받는 버전과 3개 받는 버전, 총 2개의 버전이 있다고 알리는 것과 같다.

오버로드 시그니처를 만들었다면 다음으로 구현 시그니처를 만들어 줘야 한다. 구현 시그니처는 실제로 함수가 어떻게 실행될 것인지를 정의하는 부분이다.

// 오버로드 시그니처
...

// 구현 시그니처
function func(a: number, b?: number, c?: number) {
  if (typeof b === "number" && typeof c === "number") {
    console.log(a + b + c);
  } else {
    console.log(a * 20);
  }
}

func(1);        // ✅ 버전 1 - 오버로드 시그니쳐
func(1, 2);     // ❌
func(1, 2, 3);  // ✅ 버전 3 - 오버로드 시그니쳐

주의해야할 점은 구현 시그니처의 매개변수 타입은 모든 오버로드 시그니처와 호환되도록 만들어야 한다. 따라서 위 코드에서는 매개변수 b와 c를 선택적 매개변수로 만들어 모든 오버로드 시그니처와 호환되도록 만들어주었다.


타입 가드 함수

타입스크립트에서는 참 또는 거짓을 반환하는 함수를 이용해 우리 입맛대로 사용자 정의 타입 가드를 만들 수 있다.

지금까지는 유니온 타입에서 in 연산자를 이용해 타입을 좁히거나 별도의 태그를 만들어 서로소 유니온 타입을 이용해 타입을 좁혀왔다.

잘 모르겠다면 ➡️ 타입 단언과 타입 좁히기(타입 가드)

하지만 만약 in 연산자를 통해 참조했던 프로퍼티의 이름이 변경되었을 때, 혹은 태그 프로퍼티의 값이 변경되었을 경우에는 타입가드가 제대로 동작하지 않을 수 있다.

따라서 이럴 때에는 다음과 같이 함수를 이용해 커스텀 타입 가드를 만들어 타입을 좁히는 것이 더 좋다.

function isDog(animal: Animal): animal is Dog {
  return (animal as Dog).isBark !== undefined;
}

function isCat(animal: Animal): animal is Cat {
  return (animal as Cat).isScratch !== undefined;
}

function warning(animal: Animal) {
  if (isDog(animal)) {
    console.log(animal.isBark ? "짖습니다" : "안짖어요");
  } else {
    console.log(animal.isScratch ? "할큅니다" : "안할퀴어요");
  }
}

isDog 함수는 매개변수로 받은 값이 Dog 타입일 경우 true, 아니라면 false를 반환한다. 이때 반환값의 타입으로 animal is Dog를 정의할 경우, 이 함수가 true를 반환하면 조건문 내부에서는 이 값이 Dog 타입임을 보장한다는 의미가 된다. 따라서 warning 함수에서 isDog 함수를 호출해 매개변수의 값이 Dog 타입인지 확인하고 타입을 좁힐 수 있다.


출처

위 포스트는 이정환 님의 인프런: 한 입 크기로 잘라먹는 타입스크립트(TypeScript)를 수강한 뒤 복습 차원에서 저의 생각을 정리 및 추가하여 업로드했음을 알립니다.

댓글남기기