함수형 디자인: Typescript에서의 TDD (declare의 남용)

본 포스트는 fp-ts 공식 문서의 Learning Resources에 있는 Functional design series에서 소개하는 문서들을 번역하며 학습한 문서입니다. 원본 문서는 링크에서 확인할 수 있으며 작성한 코드들은 여기에서 확인할 수 있습니다.

함수형 디자인: Typescript에서의 TDD (declare의 남용)

타입 주도 개발(TDD)은 문제를 더 작은 문제들로 나누기 위해 사용되는 기술로, 타입 검사기가 구체적인 구현을 제안하거나 최소한 목표를 달성하는 데 도움을 줍니다. 아래에 실용적인 예시가 있습니다.

예를 들어, Promise.all 함수를 다시 구현하고 싶다면 이것을 sequence로 부르고 이것의 시그니처부터 시작하겠습니다.

// TODO
declare function sequence<T>(
  promises: Array<Promise<T>>,
): Promise<Array<T>>;

선언된(declared) 함수는 아직 구현되지 않았지만 즉시 사용할 수 있으며 나머지 코드의 타입 검사에 사용될 수 있습니다. 하지만 빌드 시스템을 실행하는 경우 런타임에 이러한 함수가 없기 때문에 오류가 발생합니다. 현재로서는 “타입의 세계”에만 존재합니다.

그러나 이 기술의 목표는 코드를 실행할 필요 없이 가능한 한 오랫동안 편집기에서 작업하는 것입니다. 우리는 타입 검사기에 전적으로 의존해 오류가 발생하지 않도록 할 것입니다.

문제로 돌아가서 A 타입의 값 배열을 B 타입의 값으로 변환(또는 “축소”)해야 합니다.

우리가 찾고 있는 변환은 실제로 아래와 같은 시그니처가 있는 reduce입니다.

declare function reduce<A, B>(
  this: Array<A>,
  f: (acc: B, x: A,
) => B, init: B): B;

finit을 모두 구현하는 방법을 아직 모르지만, 이전처럼 구체적인 구현을 건너뛸 수 있습니다. 지금은 단순히 누락된 비트를 선언(declare)하고 타입 매개변수 AB를 우리가 작업 중인 타입으로 바꾸는 것으로 충분합니다.

A = Promise<T>

B = Promise<Array<T>>

우리는 아래와 같은 결과를 얻을 수 있습니다.

// TODO
declare function pushPromise<T>(
  acc: Promise<Array<T>>,
  x: Promise<T>,
): Promise<Array<T>>;

// TODO
function sequence<T>(promises: Array<Promise<T>>): Promise<Array<T>> {
  declare const init: Promise<Array<T>>; // TypeScript 오류
  return promises.reduce(pushPromise, init);
}

declare는 함수 본문 안에서 사용할 수 없음으로 임시적인 해결 방법이 필요합니다.

declare const TODO: any;

// TODO
declare function pushPromise<T>(acc: Promise<Array<T>>, x: Promise<T>): Promise<Array<T>>;

// 부분적으로 구현되었다.
function sequence<T>(promises: Array<Promise<T>>): Promise<Array<T>> {
  const init: Promise<Array<T>> = TODO;
  return promises.reduce(pushPromise, init);
}

이제 init은 간단합니다. 빈 배열을 Promise “컨테이너”에 넣을 수 있습니다.

const init: Promise<Array<T>> = Promise.resolve([]);

pushPromise를 구현하는 것은 더 복잡합니다. 우리는 Array<T> 타입의 값을 T 타입의 값과 연결해 Array<T> 타입의 다른 값을 얻는 방법을 알고 있습니다. 이것의 이름은 push로 부르겠습니다.

declare function push<T>(x: Array<T>, y: T): Array<T>;

Promise가 모두 주어지면 pushPromise에서 accx를 어떻게 연결할 수 있을까요?

우리가 필요한 것은 프로시저이며 liftA2로 부르겠습니다. 이 프로시저는 Promise “안의” 값에 대해 작업할 수 있는 새 함수를 생성하는 함수 push를 “들어올릴” 수 있습니다. 다시 우리는 구현 없이 예상 결과를 선언(declare)합니다.

// TODO
declare function liftA2<A, B, C>(
  f: (a: A, b: B) => C,
): (fa: Promise<A>, fb: Promise<B>) => Promise<C>;

// TODO
declare function push<T>(x: Array<T>, y: T): Array<T>;

// 구현되었다.
function pushPromise<T>(acc: Promise<Array<T>>, x: Promise<T>): Promise<Array<T>> {
  return liftA2<Array<T>, T, Array<T>>(push)(acc, x);
}

// 구현되었다.
function sequence<T>(promises: Array<Promise<T>>): Promise<Array<T>> {
  const init: Promise<Array<T>> = Promise.resolve([]);
  return promises.reduce(pushPromise, init);
}

이제 liftA2push만 구현하면 됩니다.

function liftA2<A, B, C>(
  f: (a: A, b: B) => C
): (fa: Promise<A>, fb: Promise<B>) => Promise<C> {
  return (a, b) => a.then(aa => b.then(bb => f(aa, bb)));
}

function push<T>(x: Array<T>, y: T): Array<T> {
  return x.concat([y]);
}

function pushPromise<T>(acc: Promise<Array<T>>, x: Promise<T>): Promise<Array<T>> {
  return liftA2<Array<T>, T, Array<T>>(push)(acc, x);
}

function sequence<T>(promises: Array<Promise<T>>): Promise<Array<T>> {
  const init: Promise<Array<T>> = Promise.resolve([]);
  return promises.reduce(pushPromise, init);
}

우리는 아래와 같이 구현한 함수를 사용해볼 수 있습니다.

sequence([]).then((x) => console.log(x)); // []
sequence([
  Promise.resolve(1),
  Promise.resolve(2),
  Promise.resolve(3)],
).then((x) =>
  console.log(x)
); // [1, 2, 3]

위에서 확인할 수 있듯이 강력한 타입 시스템은 오류를 방지할 수 있을 뿐만 아니라 설계 단계에서 사용자를 안내하고 피드백을 제공할 수 있습니다.


Written by@Minsu Kim
Software Engineer at KakaoPay Corp.