타입스크립트에서 함수 문법


원글: https://kentcdodds.com/blog/typescript-function-syntaxes - Kent C. Dodds

Software Engineer, React Training, Testing JavaScript Training

Photo by hao wang

간단한 예제로 알아보는 타입스크립트에서 다양한 함수와 함수 타입을 위한 문법

자바스크립트에는 함수를 작성하는 방법이 많다. 타입스크립트에서 갑자기 이 모든 것을 섞어 버리면 생각할 것이 많아진다. 그래서 몇몇 친구들의 도움으로, 필자는 여러분이 보통 필요하거나 실행할 다양한 함수의 형태를 간단한 예와 함께 나열해 보았다. 다른 문법의 조합은 매우 많다는 것을 유념하라. 어떤 면에서는 독특하거나 덜 명확한 조합들을 소개한다. 먼저, 필자가 항상 문법적으로 가장 혼란스러운 것은 리턴 타입을 어디에 둘까 하는 것이다. :=>는 언제 사용하는 건지. 여러분이 이 글을 빠르게 훑어보겠다면 다음 몇 개의 예제가 속도를 높이는데 도움 될 것이다.

// 함수의 간단한 타입 정의에는 =>를 쓰라.
type FnType = (arg: ArgType) => ReturnType;

// 다른 모든 경우는 :를 쓰라.
type FnAsObjType = {
  (arg: ArgType): ReturnType;
};
interface InterfaceWithFn {
  fn(arg: ArgType): ReturnType;
}

const fnImplementation = (arg: ArgType): ReturnType => {
  /* 구현부 */
};

필자에게는 이것이 가장 큰 혼란의 원인이었던 것 같다. 이 글을 쓰고 나니, 지금은 => ReturnType을 사용하는 때는 타입 그 자체로 함수 타입을 정의해야 하는 때임을 알게 되었다. 그 외에는 : ReturnType을 사용한다. 일반적인 코드 예제에서 이 기능이 어떻게 동작하는지 다양한 예제를 읽어보자.

함수 선언

// 추론된 리턴 타입
function sum(a: number, b: number) {
  return a + b;
}
// 정의된 리턴 타입
function sum(a: number, b: number): number {
  return a + b;
}

다음 예제에서 명시적인 리턴 타입을 사용할 것이지만 기술적으로는 반드시 작성할 필요는 없다.

함수 표현식

// 이름 있는 함수 표현식
const sum = function sum(a: number, b: number): number {
  return a + b;
};
// 이름 없는 함수 표현식
const sum = function (a: number, b: number): number {
  return a + b;
};
// 화살표 함수
const sum = (a: number, b: number): number => {
  return a + b;
};
// 리턴 생략
const sum = (a: number, b: number): number => a + b;
// 객체의 리턴 생략은 중괄호를 구분하기 위해서 괄호가 필요하다.
const sum = (a: number, b: number): { result: number } => ({ result: a + b });

또한 변수 옆에 타입 어노테이션을 추가하고 함수 스스로 타입을 추론할 수 있게 한다.

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

그리고 여러분은 해당 타입을 추출할 수 있다.

type MathFn = (a: number, b: number) => number;
const sum: MathFn = (a, b) => a + b;

아니면 객체 타입 문법을 사용할 수 있다.

type MathFn = {
  (a: number, b: number): number;
};
const sum: MathFn = (a, b) => a + b;

여러분이 함수에 타입이 지정된 속성을 추가하려고 할 때 유용할 것이다.

type MathFn = {
  (a: number, b: number): number;
  operator: string;
};
const sum: MathFn = (a, b) => a + b;
sum.operator = '+';

이것을 인터페이스로 만들 수도 있다.

interface MathFn {
  (a: number, b: number): number;
  operator: string;
}
const sum: MathFn = (a, b) => a + b;
sum.operator = '+';

그리고 "이봐, 여기 이런 이름과 타입으로 된 변수가 있어"라고 의미하는 declare function과 declare namespace 도 있다.

declare function MathFn(a: number, b: number): number;
declare namespace MathFn {
  let operator: '+';
}
const sum: typeof MathFn = (a, b) => a + b;
sum.operator = '+';

주어진 type과 interfacedeclare function 중에서, 필자는 interface가 제공하는 확장성이 필요하기 전까지는 개인적으로 type을 선호한다. 라이브러리처럼 알 수 없는 뭔가에 대해 컴파일러에 꼭 알려주고 싶은 경우에만 declare를 사용한다.

선택적(Optional)/기본값(default) 매개변수

선택적 매개변수는 다음과 같다.

const sum = (a: number, b?: number): number => a + (b ?? 0);

여기서는 순서가 중요하다. 여러분이 매개변수 하나를 선택적으로 만든다면, 뒤따라 오는 모든 매개변수 또한 선택적 매개변수가 되어야 한다. 왜냐하면 sum(1)은 호출할 수 있지만 sum(, 2)는 호출할 수 없기 때문이다. 그러나 sum(undefined, 2)는 호출할 수 있는데 여러분이 허용하는 것이라면 다음과 같이 쓸 수 있다.

const sum = (a: number | undefined, b: number): number => (a ?? 0) + b

기본값 매개변수 이 글을 쓸 때, 필자는 매개변수를 선택적으로 만들지 않고 기본값 매개변수로 사용하는 것은 필요가 없으리라 생각했지만, 기본값을 가질 때 타입스크립트가 그것을 선택적 매개변수와 같이 취급한다는 것을 알게 되었다. 그러므로 다음도 동작한다.

const sum = (a: number, b: number = 0): number => a + b;
sum(1); // 결과는 1이다.
sum(2, undefined); // 결과는 2다.

그러므로 이 예제는 다음과 기능적으로 동일하다.

const sum = (a: number, b: number | undefined = 0): number => a + b;

오늘 나는 새롭게 알게 되었다(역> TIL).

흥미롭게도 이것은 여러분이 첫 번째 인자를 선택적으로 두고 두 번째 인자를 필수라고 두더라도 | undefined를 사용하지 않을 수 있음을 의미한다.

const sum = (a: number = 0, b: number): number => a + b;
sum(undefined, 3); // 결과는 3이다

하지만 = 0은 자바스크립트 표현식이고 타입이 아니기 때문에, 타입을 선언할 때는| undefined를 꼭 써야 할 것이다.

type MathFn = (a: number | undefined, b: number) => number;
const sum: MathFn = (a = 0, b) => a + b;

나머지(Rest) 매개변수

나머지 매개변수는 함수 호출에서 매개변수의 "나머지"를 배열로 모아주는 자바스크립트 기능이다. 매개변수의 어떤 위치(첫 번째, 두 번째, 세 번째, 등)에라도 사용할 수 있다. 단 유일한 조건은 마지막 매개변수여야 한다는 것이다.

const sum = (a: number = 0, ...rest: Array<number>): number => {
  return rest.reduce((acc, n) => acc + n, a);
};

그리고 타입을 정의해 보자.

type MathFn = (a?: number, ...rest: Array<number>) => number;
const sum: MathFn = (a = 0, ...rest) => rest.reduce((acc, n) => acc + n, a);

객체의 속성과 함수

다음 예제는 객체의 함수다.

const math = {
  sum(a: number, b: number): number {
    return a + b;
  },
};

다음은 속성이 함수 표현식인 예제다.

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

속성이 (리턴을 생략한) 화살표 함수인 예제다.

const math = {
  sum: (a: number, b: number): number => a + b,
};

아쉽지만, 함수 스스로를 정의할 수 없는 타입을 작성하기 위해서는 객체로 감싸는 형태로 타입을 정의해야 한다. 객체 리터럴 안에 정의될 때는 속성의 함수 타입을 스스로 지정할 수 없다.

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

const math: { sum: MathFn } = {
  sum: (a, b) => a + b,
};

심지어, 앞선 예제처럼 타입에 속성을 추가하려고 한다면 객체 리터럴 안에서는 불가능하다. 함수 선언을 완전하게 타입 정의해야 한다.

type MathFn = {
  (a: number, b: number): number;
  operator: string;
};
const sum: MathFn = (a, b) => a + b;
sum.operator = '+';

const math = { sum };

이 예제는 const math = {sum}에 더하기만 추가한 앞선 예제와 같다는 것을 눈치챘을 것이다. 그렇다, 객체 선언에서 이 모든 것을 인라인화할 수 있는 방법은 없다.

클래스

클래스는 그 자체로는 (new와 함께 호출되어야 하는) 특별한 함수지만, 이번에는 클래스 본문에서 함수가 어떻게 정의되는지 이야기할 것이다. 클래스 본문 안에서 가장 흔한 형태의 함수 예제다.

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

const math = new MathUtils();
math.sum(1, 2);

이 클래스의 어떤 인스턴스에 바인딩하고 싶은 함수가 있다면 클래스 필드를 사용할 수도 있다.

class MathUtils {
  sum = (a: number, b: number): number => {
    return a + b;
  };
}

// 다음과 같이 하면 그렇게 할 수 있다.
const math = new MathUtils();
const sum = math.sum;
sum(1, 2);

// 그러나 일반적인 객체의 속성에 대해서 클래스로 사용하는 방식은 대부분의 성능 향상을 상쇄시켜 버릴 수 있다.

그러면 타입을 정의해 보자. 첫 번째 예제의 메서드의 타입 정의는 다음과 같다.

interface MathUtilsInterface {
  sum(a: number, b: number): number;
}

class MathUtils implements MathUtilsInterface {
  sum(a: number, b: number): number {
    return a + b;
  }
}

흥미롭게도, 상속 구현해야 하는 인터페이스의 일부임에도 불구하고 여전히 그 함수를 위한 타입을 정의해야 한다🤔 🤷‍♂️.

마지막으로 알아야 할 것이다. 타입스크립트에서는 public과 privateprotected를 쓸 수 있다. 개인적으로는 클래스를 잘 쓰지 않고 이들을 위한 특정 타입스크립트 기능을 좋아하지 않는다.  자바스크립트에 private 멤버를 위한 깔끔하면서도 특별한 문법이 나올 것이다(더 배워보기).

모듈

함수 정의를 가져오기(import)와 내보내기(export) 하는 것은 다른 것과 마찬가지 방식으로 동작한다. 타입스크립트만의 특별한 것은 모듈을 정의하면서 .d.ts 파일을 만들 때다.

const sum = (a: number, b: number): number => a + b;
sum.operator = '+';

다음 예제는 기본으로 내보내기(default export)이다.

declare const sum: {
  (a: number, b: number): number;
  operator: string;
};
export default sum;

그리고 다음 예제는 이름 있는 내보내기(named export)이다.

declare const sum: {
  (a: number, b: number): number;
  operator: string;
};
export { sum };

오버로드

이 특별한 기능에 대해 글을 썼고 타입스크립트에서 함수 오버로드를 정의하는 법에서 읽을 수 있다. 그 글에서 발췌한 예제다.

type asyncSumCb = (result: number) => void;
// 모든 유효한 함수 원형을 정의한다.
function asyncSum(a: number, b: number): Promise<number>;
function asyncSum(a: number, b: number, cb: asyncSumCb): void;
// 실제 함수 구현을 작성한다.
// cb가 선택적 매개변수인 것을 주목하라.
// 또한 리턴 타입이 추론되었다는 것을 주목하라. 하지만 `void | Promise<number>`로 명시할 수도 있다.
function asyncSum(a: number, b: number, cb?: asyncSumCb) {
  const result = a + b;
  if (cb) return cb(result);
  else return Promise.resolve(result);
}

기본적으로 여러분이 할 것은 함수를 여러 번 타입 정의하고 마지막에 함수 구현을 작성하는 것이다. 함수 구현을 위한 타입 정의는 오버라이드할 모든 타입을 지원하는 것이 중요하며, 이것이 이 예제에서 cb가 선택적 매개변수인 이유다.

제너레이터

필자는 프로덕션 코드에서는 제너레이터를 한 번도 써보지 않았다... 하지만 타입스크립트로 이것저것 테스트해 보았을 때, 간단한 예제라도 간단한 것이 없었다.

function* generator(start: number) {
  yield start + 1;
  yield start + 2;
}

var iterator = generator(0);
console.log(iterator.next()); // { value: 1, done: false }
console.log(iterator.next()); // { value: 2, done: false }
console.log(iterator.next()); // { value: undefined, done: true }

타입스크립트는  iterator.next() 가 다음 타입의 객체를 리턴하는 것을 정확하게 추론한다.

type IteratorNextType = {
  value: number | void;
  done: boolean;
};

만일 yield 표현식이 수행된 값을 위해 안전하게 타입을 쓰고 싶다면 다음 예제와 같이 할당할 변수에 어노테이션 타입을 추가하라.

function* generator(start: number) {
  const newStart: number = yield start + 1;
  yield newStart + 2;
}

var iterator = generator(0);
console.log(iterator.next()); // { value: 1, done: false }
console.log(iterator.next(3)); // { value: 5, done: false }
console.log(iterator.next()); // { value: undefined, done: true }

그리고  iterator.next(3) 대신  iterator.next('3') 를 호출하면 컴파일 에러를 만날 수 있을 것이다🎉.

Async

타입스크립트에서 async/await 함수는 자바스크립트와 같이 동작하며 타입을 정의할 때 다른 것이 있다면 리턴 타입이 항상 Promise 제네릭(generic)이라는 것이다.

const sum = async (a: number, b: number): Promise<number> => a + b;
async function sum(a: number, b: number): Promise<number> {
  return a + b;
}

제네릭

함수 선언에서 예제다.

function arrayify2<Type>(a: Type): Array<Type> {
  return [a];
}

아쉽지만, (JSX에서 설정된 타입스크립트일 때는) 화살표 함수를 쓸 때는 함수에서 여는 <는 컴파일러에 모호하다.  "제네릭 문법인가? 아니면 JSX 문법인가?" 그래서 이를 구분하기 위해서 약간의 도움을 주어야 한다. 가장 직관적인 방법은 extends unknown을 쓰는 것이다.

const arrayify = <Type extends unknown>(a: Type): Array<Type> => [a];

extends 문법을 제네릭으로 편리하게 볼 수 있는 예제다.

타입 가드

타입 가드는 타입을 좁혀주는 메카니즘이다. 예를 들어, string | number를 타입을 string이나 number로 좁힐 수 있도록 해준다. 이런 경우(typeof x === 'string'와 같은)를 위한 내장 메카니즘이 있지만, 여러분만의 것을 만들 수도 있다. 다음은 필자가 가장 좋아하는 예제(처음 나에게 이것을 보여주었던 내 친구 Peter에게 주는 팁)이다.

falsy 값을 가지는 배열이 있고 그 값을 제거하는 예제다.

// Array<number | undefined>
const arrayWithFalsyValues = [1, undefined, 0, 2];

보통 자바스크립트에서는 이렇게 할 수 있다.

// Array<number | undefined>
const arrayWithoutFalsyValues = arrayWithFalsyValues.filter(Boolean);

아쉽지만, 타입스크립트는 타입을 좁혀주는 가드를 고려하지 못한다. 그래서 타입은 (좁혀지지 않은 채) 여전히 Array<number | undefined> 이다.

그래서 우리는 함수를 작성해서 컴파일러에 주어진 인자가 특정 타입인지 여부에 대해 true/false를 리턴한다고 알려줄 수 있다. 우리는 어떤 주어진 인자의 타입이 falsy 값의 타입이 아니라면 우리 함수가 true를 리턴한다고 할 수 있다.

type FalsyType = false | null | undefined | '' | 0;
function typedBoolean<ValueType>(value: ValueType): value is Exclude<ValueType, FalsyType> {
  return Boolean(value);
}

그리고 이것을 활용해서 다음과 같이 할 수 있다.

// Array<number>
const arrayWithoutFalsyValues = arrayWithFalsyValues.filter(typedBoolean);

우와!

단언 함수

여러분이 무언가에 대해서 매우 확실함을 얼마나 자주 런타임 체크를 하는지 알고 있는가? 가령, 객체가 어떤 값이나 null을 갖는 속성을 가지며 그것이 null인지 체크하고 null일 때 예외를 발생시킬 수 있을 것이다. 예를 들어 다음과 같은 작업을 할 수 있을 것이다.

type User = {
  name: string;
  displayName: string | null;
};

function logUserDisplayNameUpper(user: User) {
  if (!user.displayName) throw new Error('이런, 사용자의 displayName이 없습니다.');
  console.log(user.displayName.toUpperCase());
}

타입스크립트는 user.displayName.toUpperCase() 에 오류를 발생하지 않는데, 왜냐하면 if 구문이 그것을 이해하게 해주는 타입 가드이기 때문이다. 다음 예제는 if로 확인하는 것을 함수 안으로 넣는 것이다.

type User = {
  name: string;
  displayName: string | null;
};

function assertDisplayName(user: User) {
  if (!user.displayName) throw new Error('이런, 사용자의 displayName이 없습니다.');
}

function logUserDisplayName(user: User) {
  assertDisplayName(user);
  console.log(user.displayName.toUpperCase());
}

이제 타입스크립트는 assertDisplayName 호출이 충분한 타입 가드가 아니기 때문에 문제가 발생하게 되었다. 필자는 이것이 타입스크립트의 한계라고 생각한다. 이봐, 완벽한 기술은 없어. 어쨌든 우리의 함수가 단언을 만들어 낸다는 것을 알려줌으로써 타입스크립트를 살짝 도와줄 수 있다.

type User = {
  name: string;
  displayName: string | null;
};

function assertDisplayName(user: User): asserts user is User & { displayName: string } {
  if (!user.displayName) throw new Error('이런, 사용자의 displayName이 없습니다.');
}

function logUserDisplayName(user: User) {
  assertDisplayName(user);
  console.log(user.displayName.toUpperCase());
}

그리고 이것이 타입을 좁혀주는 함수로 만드는 또 하나의 방법이다.

결론

이게 전부는 아니지만, 타입스크립트에서 함수를 다룰 때 필자가 흔히 쓰는 문법이다. 여러분에게 도움이 되었기를 바란다. 이 글을 즐겨찾기하고 친구들에게 공유하라😘.

유동식2021.05.21
Back to list