fp-ts로 Typescript 함수형 프로그래밍 시작하기 1 (Eq)

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

fp-ts 시작하기 (Eq)

이 블로그 시리즈에서는 종종 “타입 클래스”와 “인스턴스”에 대해 이야기할 것입니다. 그것들이 무엇이고 fp-ts에서 어떻게 인코딩되는지 살펴보겠습니다.

위키피디아의 “type class”

프로그래머는 클래스에 속하는 모든 유형에 대해 존재해야 하는 해당 타입과 함께 함수 또는 상수 이름 집합을 지정하여 타입 클래스를 정의합니다.

fp-ts에서 타입 클래스는 TypeScript의 interface로 인코딩됩니다.

Eq 타입 클래스

동등성을 허용하는 타입을 포함하기 위한 타입 클래스 Eq는 아래와 같이 선언됩니다.

interface Eq<A> {
  /** `x`와 `y`가 같을 경우 `true`를 반환한다. */
  readonly equals: (x: A, y: A) => boolean;
}

선언은 아래와 같이 읽을 수 있습니다.

A 타입은 정의된 적절한 타입과 equal이라는 이름의 함수가 있는 경우 타입 클래스 Eq에 속합니다.

instance는 아래와 같습니다.

프로그래머는 특정 타입 A에 대한 모든 C 멤버의 구현을 정의하는 인스턴스 선언을 사용하여 어떤 타입 A를 주어진 타입 클래스 C의 멤버로 만들 수 있습니다.

fp-ts 인스턴스는 정적인 딕셔너리로 인코딩됩니다.

예를 들어 아래는 number 타입에 대한 Eq의 인스턴스입니다.

const eqNumber: Eq<number> = {
  equals: (x, y) => x === y,
};

인스턴스는 아래의 규칙을 만족합니다.

  1. 반사성(Reflexivity): A의 모든 x에 대하여 equals(x, x) === true를 만족한다.
  2. 대칭(Symmetry): A의 모든 x, y에 대하여 equals(x, y) === equals(y, x)를 만족한다.
  3. 이동성(Transitivity): A의 모든 x, y, z에 대하여 equals(x, y) === true이고 equals(y, z) === true라면 equals(x, z) === true를 만족한다.

elem 함수

프로그래머는 아래와 같은 방법으로 elem(요소가 배열에 있는지를 결정하는) 함수를 정의할 수 있다.

function elem<A>(E: Eq<A>): (a: A, as: Array<A>) => boolean {
  return (a, as) => as.some(item => E.equals(item, a));
}

number타입의 Eq 인스턴스인 eqNumber를 이용한 elem 함수를 테스트하는 코드는 아래와 같다. elem 함수가 반환하는 boolean 타입값을 이용해 toBeTruthy, toBeFalsy 함수를 이용해 확인할 수 있다.

describe('elem 함수 테스트', () => {
  it('eqNumber를 이용한 elem 함수 테스트 (요소가 있는 경우)', () => {
    expect(elem(eqNumber)(1, [1, 2, 3])).toBeTruthy();
  });
  it('eqNumber를 이용한 elem 함수 테스트 (요소가 없는 경우)', () => {
    expect(elem(eqNumber)(4, [1, 2, 3])).toBeFalsy();
  });
});

더 복잡한 타입에 대한 Eq 인스턴스를 작성해 보겠습니다.

type Point = {
  x: number;
  y: number;
};

const eqPoint: Eq<Point> = {
  equals: (p1, p2) => p1.x === p2.x && p1.y === p2.y,
};

참조 동등성을 먼저 확인하여 equals를 최적화 할 수도 있습니다.

const eqPoint: Eq<Point> = {
  equals: (p1, p2) => p1 === p2 || (p1.x === p2.x && p1.y === p2.y),
};

하지만 이것은 대부분 보일러플레이트 입니다. 좋은 소식은 우리가 각 필드에 Eq 인스턴스를 제공할 수 있다면 Point와 같은 구조에 대한 Eq 인스턴스를 만들 수 있다는 것이다.

실제로 fp-ts/lib/Eq 모듈이 getStructEq 콤비네이터을 내보냅니다.

원문에서는 getStructEq를 사용하라고 작성되어 있지만, 최신 버전의 fp-ts에서는 deprecated 되어 있으며 struct를 사용하면 됩니다.

const eqPoint: Eq<Point> = struct({
  x: eqNumber,
  y: eqNumber,
});

방금 정의한 인스턴스로 struct를 계속 지원할 수 있습니다.

const eqVector: Eq<Vector> = struct({
  from: eqPoint,
  to: eqPoint,
});

structfp-ts에 의해 제공되는 유일한 콤비네이터가 아닙니다. fp-ts/lib/Array에는 배열을 위한 Eq 인스턴스를 도출할 수 있는 콤비네이터가 있습니다.

import { getEq } from 'fp-ts/lib/Array';

const eqArrayOfPoints: Eq<Array<Point>> = getEq(eqPoint);

마지막으로 Eq 인스턴스를 구축할 수 있는 또 다른 유용한 방법은 contramap 콤비네이터입니다.

  • contramap 콤비네이터 타입 정의
const contramap = <A, B>(f: (b: B) => A) => (fa: Eq<A>) => Eq<B>;

contramap 콤비네이터는 A에 대한 Eq의 인스턴스와 B에서 A로의 함수가 주어지면 B에 대한 Eq의 인스턴스를 파생시킬 수 있습니다.

import { contramap } from 'fp-ts/lib/Eq';
import { eqNumber } from './eqNumber';

type User = {
  userId: number;
  name: string;
};

/** 두 User는`userId` 필드가 같으면 같습니다. */
export const eqUser = contramap((user: User) => user.userId)(eqNumber);

eqUser에서 사용한 contramap 콤비네이터는 A 타입으로 number 타입을 받고 B 타입으로 User 타입을 받으며 number 타입인 User 타입의 userId 필드를 eqNumber 함수를 이용해 동등성을 판단합니다.

  • eqUser에 사용된 contramp 함수의 타입 정의
const contramap = <number, User>(f: (b: User) => number) =>
  (fa: Eq<number>) => Eq<User>;

작성한 eqUser 인스턴스는 아래와 같이 테스트할 수 있습니다.

describe('Eq 인터페이스를 구현한 eqUser 인스턴스 테스트', () => {
  it('eqUser 인스턴스 equals 함수 테스트', () => {
    expect(
      eqUser.equals(
        { userId: 1, name: 'Giulio' },
        { userId: 1, name: 'Giulio Canti' }
      )
    ).toBeTruthy();
    expect(
      eqUser.equals(
        { userId: 1, name: 'Giulio' },
        { userId: 2, name: 'Giulio' }
      )
    ).toBeFalsy();
  });
});

eqUser 인스턴스의 equals 함수는 두 개의 인자로 받은 두 User 타입 객체의 userId가 같은 경우 true를 반환합니다. userId 필드는 앞에서 구현했던 eqNumber 인스턴스의 equals 함수를 이용해 값이 같은지 확인합니다.


Written by@Minsu Kim
Software Engineer at KakaoPay Corp.