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

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

fp-ts 시작하기 (Ord)

Eq에 대한 이전 블로그 게시물에서는 동등성에 대한 개념을 다루고 있었습니다. 이 블로그 게시물에서는 순서의 개념을 다루려고 합니다.

전체 순서를 허용하는 타입을 포함하는 타입 클래스 Ord는 아래와 같은 방법으로 선언됩니다.

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

type Ordering = -1 | 0 | 1;

interface Ord<A> extends Eq<A> {
  readonly compare: (x: A, y: A) => Ordering;
}

두 개의 값 x, y는 아래와 같이 비교할 수 있습니다.

  • x < y이거나 compare(x, y)-1인 경우
  • xy가 같거나 compare(x, y)0인 경우
  • x > y이거나 compare(x, y)1인 경우

결과적으로 compare(x, y) <= 0인 경우에 x <= y라고 말할 수 있습니다.

아래 예시는 number 타입에 대한 Ord 인스턴스입니다.

const ordNumber: Ord<number> = {
  equals: (x, y) => x === y,
  compare: (x, y) => (x < y ? -1 : x > y ? 1 : 0),
};

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

  1. 반사성(Reflexivity): A의 모든 x에 대하여 compare(x, x) === 0을 만족한다.
  2. 반대칭(Antisymmetry): A의 모든 x, y에 대하여 compare(x, y) <= 0이고 compare(y, x) <= 0이면 xy는 같다.
  3. 이동성(Transitivity): A의 모든 x, y, z에 대하여 compare(x, y) <= 0이고 compare(y, z) <= 0이면 compare(x, z) <= 0을 만족한다.

또한 compareEqequals와 일치해야 합니다.

A의 모든 x, y에 대하여 compare(x, y) === 0일 경우 equals(x, y) === true의 경우만 만족합니다.

참고: 규칙에 따르는 equals는 아래와 같이 compare에서 도출할 수 있습니다.

equals: (x, y) => compare(x, y) === 0;

실제로 fp-ts/lib/Ord 모듈에는 간단히 비교 함수를 지정하여 Ord 인스턴스를 정의할 수 있는 편리한 fromCompare 헬퍼 함수가 있습니다.

import type { Ord } from 'fp-ts/lib/Ord';
import { fromCompare } from 'fp-ts/lib/Ord';

const ordNumber: Ord<number> = fromCompare((x, y) =>
  x < y ? -1 : x > y ? 1 : 0
);

프로그래머는 아래와 같은 방법으로 min 함수를 정의할 수 있습니다.

function min<A>(O: Ord<A>): (x: A, y: A) => A {
  return (x, y) => (O.compare(x, y) === 1 ? y : x);
}

작성한 min 함수는 아래와 같이 테스트할 수 있습니다. min 함수는 Ord 인스턴스를 받아 x, y의 값을 compare로 함수로 비교해 x, y 중에서 작은 값을 반환합니다.

describe('Ord 인터페이스를 이용한 min 함수 테스트', () => {
  it('ordNumber를 이용한 min 함수 테스트 ', () => {
    expect(min(ordNumber)(2, 1)).toBe(1);
    expect(min(ordNumber)(2, 3)).toBe(2);
  });
});

toBe 함수를 이용해 min 함수가 반환한 함수에 인자로 넣은 값 중에 작은 값이 정상적으로 반환되는지 확인할 수 있습니다.

number 타입에 관해 이야기 할 때에는 전체성(Totality)은 분명해 보일 수 있지만 (즉, x <= y 또는 y <= x) 항상 그런 것은 아닙니다. 더 복잡한 타입을 고려해 볼 수 있습니다.

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

Ord<User> 인터페이스를 어떻게 정의할 수 있나요?

실제로는 상황에 따라 다르지만 가능한 선택으로는 age 속성으로 사용자를 정렬하는 것입니다.

const byAge: Ord<User> = fromCompare((x, y) => ordNumber.compare(x.age, y.age));

contramap 콤비네이터를 사용하여 일부 자주 사용하는 구문을 피할 수 있습니다. A에 대한 Ord 인스턴스와 B에서 A로의 함수가 주어지면 B에 대한 Ord의 인스턴스를 파생시킬 수 있습니다.

import { contramap } from 'fp-ts/lib/Ord';

const byAge: Ord<User> = contramap((user: User) => user.age)(ordNumber);

이제 min을 사용하여 두 User 중 더 어린 User를 선택할 수 있습니다.

const getYounger = min(byAge);

작성한 getYounger 함수는 아래와 같이 테스트할 수 있습니다.

describe('byAge 함수와 min 함수를 사용하는 getYounger 함수 테스트', () => {
  it('getYounger 함수 테스트 ', () => {
    expect(
      getYounger({ name: 'Guido', age: 48 }, { name: 'Giulio', age: 45 })
    ).toMatchObject({ name: 'Giulio', age: 45 });
  });
});

getYounger 함수로 전달된 두 User 타입 객체 중 age 속성의 값이 작은 User 타입 객체가 정상적으로 반환되는지 확인할 수 있습니다.

반대로 더 오래된 것을 선택하려면 어떻게 할 수 있을까요? 우리는 “순서를 뒤집거나” 기술적으로 말하면 이중 정렬을 받아야 합니다.

다행히도 이런 상황을 위해 fp-ts가 제공하는 다른 콤비네이터가 있습니다.

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

import type { Ord } from 'fp-ts/lib/Ord';
import { reverse } from 'fp-ts/lib/Ord';
import { byAge } from './byAge';
import { min } from './min';

function max<A>(O: Ord<A>): (x: A, y: A) => A {
  return min(reverse(O));
}

const getOlder = max(byAge);

작성한 getOlder 함수는 아래와 같이 테스트할 수 있습니다.

describe('byAge, min, reverse 함수를 사용하는 getOlder 함수 테스트', () => {
  it('getOlder 함수 테스트 ', () => {
    expect(
      getOlder({ name: 'Guido', age: 48 }, { name: 'Giulio', age: 45 })
    ).toMatchObject({ name: 'Guido', age: 48 });
  });
});

getOlder 함수로 전달된 두 User 타입 객체 중 age 속성의 값이 큰 User 타입 객체가 정상적으로 반환되는지 확인할 수 있습니다.


Written by@Minsu Kim
Software Engineer at KakaoPay Corp.