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

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

fp-ts 시작하기 (Monoid)

지난 포스트에서는 Semigroup이 (concat을 통해) 값 “병합”의 개념을 포착하는 것을 보았습니다. Monoidconcat과 관련하여 “중립”인 특별한 값을 갖는 Semigroup입니다.

타입 클래스 정의

fp-tsfp-ts/lib/Monoid 모듈에 포함된 타입 클래스 Monoid는 TypeScript interface로 구현되며 empty라고 이름이 지어진 중립 값이 존재합니다.

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

interface Monoid<A> extends Semigroup<A> {
  readonly empty: A;
}

Monoid는 아래의 규칙이 유지되어야 합니다.

  • 오른쪽 항등식(Right identity): A의 모든 x에 대하여 concat(x, empty) = x를 만족한다.
  • 왼쪽 항등식(Left identity): A의 모든 x에 대하여 concat(empty, x) = x를 만족한다.

concat의 어느 쪽이든 empty값을 주어도 값에 차이가 없어야합니다.

참고: empty 값이 있다면 고유합니다.

인스턴스

전에 살펴 본 대부분의 Semigroup은 실제로 Monoid입니다.

/** number 타입의 덧셈 `Monoid` */
export const monoidSum: Monoid<number> = {
  concat: (x, y) => x + y,
  empty: 0,
};

/** number 타입의 곱셈 `Monoid` */
export const monoidProduct: Monoid<number> = {
  concat: (x, y) => x * y,
  empty: 1,
};

export const monoidString: Monoid<string> = {
  concat: (x, y) => x + y,
  empty: '',
};

/** boolean타입의 논리곱 monoid */
export const monoidAll: Monoid<boolean> = {
  concat: (x, y) => x && y,
  empty: true,
};

/** boolean 타입의 논리합 `monoid` */
export const monoidAny: Monoid<boolean> = {
  concat: (x, y) => x || y,
  empty: false,
};

모든 SemigroupMonoid인지 궁금할 수 있지만 그렇지 않습니다. 반례로 아래의 Semigroup을 고려할 수 있습니다.

const semigroupSpace: Semigroup<string> = {
  concat: (x, y) => x + ' ' + y,
};

concat(x, empty) = x를 만족하는 같은 empty 값을 찾을 수 없습니다.

더 복잡한 타입에 대한 Monoid 인스턴스를 작성해 보겠습니다. Point와 같은 구조체에 대해 Monoid 인스턴스를 만들 수 있습니다.

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

구조체의 각 필드에 Monoid 인스턴스를 그대로 제공할 수 있습니다.

import { struct } from 'fp-ts/lib/Monoid';

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

const monoidPoint: Monoid<Point> = struct({
  x: monoidSum,
  y: monoidSum,
});

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

방금 정의된 인스턴스 또한 getStructMonoidstruct를 사용해 계속 제공할 수 있습니다.

type Vector = {
  from: Point;
  to: Point;
};

const monoidVector: Monoid<Vector> = getStructMonoid({
  from: monoidPoint,
  to: monoidPoint,
});

Folding

Semigroup 대신 Monoid를 사용하는 경우 folding이 더 간단합니다. 초깃 값을 명시적으로 제공할 필요가 없습니다. (구현에서는 Monoidempty 값을 사용할 수 있습니다)

import { concatAll } from 'fp-ts/lib/Monoid';

concatAll(monoidSum)([1, 2, 3, 4]); // 10
concatAll(monoidProduct)([1, 2, 3, 4]); // 24
concatAll(monoidString)(['a', 'b', 'c']); // 'abc'
concatAll(monoidAll)([true, false, true]); // false
concatAll(monoidAny)([true, false, true]); // true

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

타입 생성자를 위한 Monoid

A에 대한 Semigroup 인스턴스가 주어지면 Option<A>에 대한 Semigroup 인스턴스를 파생시킬 수 있다는 것을 이미 알고 있습니다.

A에 대한 Monoid 인스턴스를 찾을 수 있다면 아래와 같이 작동하는 Option<A> (getApplyMonoidgetApplicativeMonoid를 통해)에 대한 Monoid 인스턴스를 파생시킬 수 있습니다.

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

x y concat(x, y)
none none none
some(a) none none
none some(a) none
some(a) some(b) some(concat(a, b))
import { Applicative } from 'fp-ts/lib/Option';
import { getApplicativeMonoid } from 'fp-ts/lib/Applicative';

export const appliedMonoidSum = getApplicativeMonoid(Applicative)(monoidSum);

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

describe('Option타입을 지원하는 appliedMonoidSum 인스턴스 테스트', () => {
  let result;
  it('appliedMonoidSum 인스턴스 concat 함수 테스트 (some + none)', () => {
    result = appliedMonoidSum.concat(some(1), none);
    expect(result).toBe(none);
    expect(isNone(result)).toBeTruthy();
  });
  it('appliedMonoidSum 인스턴스 concat 함수 테스트 (some + some)', () => {
    result = appliedMonoidSum.concat(some(1), some(2));
    expect(result).toMatchObject(some(3));
    expect(isSome(result)).toBeTruthy();
  });
  it('appliedMonoidSum 인스턴스 concat 함수 테스트 (some + empty)', () => {
    result = appliedMonoidSum.concat(some(1), appliedMonoidSum.empty);
    expect(result).toMatchObject(some(1));
    expect(isSome(result)).toBeTruthy();
  });
});

구현된 appliedMonoidSum 인스턴스의 concat 함수가 표의 내용과 동일한 결과를 반환하는지 확인합니다.

Option<A>에 대해 두 개의 다른 Monoid를 파생시킬 수 있습니다.

1. getFirstMonoid

가장 왼쪽에 있는 None이 아닌 값을 반환하는 Monoid

x y concat(x, y)
none none none
some(a) none some(a)
none some(a) some(a)
some(a) some(b) some(a)
import { getFirstMonoid } from 'fp-ts/lib/Option';

const firstMonoid = getFirstMonoid<number>();

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

describe('첫 번째 Some타입을 반환하는 firstMonoid 인스턴스 테스트', () => {
  let result;
  it('firstMonoid 인스턴스 concat 함수 테스트 (none + none)', () => {
    result = firstMonoid.concat(none, none);
    expect(result).toMatchObject(none);
    expect(isNone(result)).toBeTruthy();
  });
  it('firstMonoid 인스턴스 concat 함수 테스트 (some + none)', () => {
    result = firstMonoid.concat(some(1), none);
    expect(result).toMatchObject(some(1));
    expect(isSome(result)).toBeTruthy();
  });
  it('firstMonoid 인스턴스 concat 함수 테스트 (none + some)', () => {
    result = firstMonoid.concat(none, some(1));
    expect(result).toMatchObject(some(1));
    expect(isSome(result)).toBeTruthy();
  });
  it('firstMonoid 인스턴스 concat 함수 테스트 (some + some)', () => {
    result = firstMonoid.concat(some(1), some(2));
    expect(result).toMatchObject(some(1));
    expect(isSome(result)).toBeTruthy();
  });
});

구현된 firstMonoid 인스턴스의 concat 함수가 표의 내용과 동일한 결과를 반환하는지 확인합니다.

2. getLastMonoid

가장 오른쪽에 있는 None이 아닌 값을 반환하는 Monoid

x y concat(x, y)
none none none
some(a) none some(a)
none some(a) some(a)
some(a) some(b) some(b)
import { getLastMonoid } from 'fp-ts/lib/Option';

export const lastMonoid = getLastMonoid<number>();

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

describe('두 번째 Some타입을 반환하는 lastMonoid 인스턴스 테스트', () => {
  let result;
  it('lastMonoid 인스턴스 concat 함수 테스트 (none + none)', () => {
    result = lastMonoid.concat(none, none);
    expect(result).toMatchObject(none);
    expect(isNone(result)).toBeTruthy();
  });
  it('lastMonoid 인스턴스 concat 함수 테스트 (some + none)', () => {
    result = lastMonoid.concat(some(1), none);
    expect(result).toMatchObject(some(1));
    expect(isSome(result)).toBeTruthy();
  });
  it('lastMonoid 인스턴스 concat 함수 테스트 (none + some)', () => {
    result = lastMonoid.concat(none, some(1));
    expect(result).toMatchObject(some(1));
    expect(isSome(result)).toBeTruthy();
  });
  it('lastMonoid 인스턴스 concat 함수 테스트 (some + some)', () => {
    result = lastMonoid.concat(some(1), some(2));
    expect(result).toMatchObject(some(2));
    expect(isSome(result)).toBeTruthy();
  });
});

구현된 lastMonoid 인스턴스의 concat 함수가 표의 내용과 동일한 결과를 반환하는지 확인합니다.

예를 들면 getLastMonoid 함수는 선택적인 값을 관리하는 데 유용할 수 있습니다.

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

import type { Monoid } from 'fp-ts/lib/Monoid';
import type { Option } from 'fp-ts/lib/Option';
import { struct } from 'fp-ts/lib/Monoid';
import { getLastMonoid } from 'fp-ts/lib/Option';

/** VSCode 설정 */
export interface Settings {
  /** font family를 제어한다. */
  fontFamily: Option<string>;
  /** font size 픽셀을 제어한다. */
  fontSize: Option<number>;
  /** 특정 수의 열만 렌더링하도록 미니 맵의 너비를 제한합니다. */
  maxColumn: Option<number>;
}

export const monoidSettings: Monoid<Settings> = struct({
  fontFamily: getLastMonoid<string>(),
  fontSize: getLastMonoid<number>(),
  maxColumn: getLastMonoid<number>(),
});

위와 같이 getLastMonoid를 이용해 관리되는 속성들을 갖는 monoidSettings 인스턴스는 아래와 같이 테스트할 수 있습니다.

describe('getLastMonoid를 사용한 monoidSettings 인스턴스 테스트', () => {
  const workspaceSettings: Settings = {
    fontFamily: some('Courier'),
    fontSize: none,
    maxColumn: some(80),
  };
  const userSettings: Settings = {
    fontFamily: some('Fira Code'),
    fontSize: some(12),
    maxColumn: none,
  };

  it('monoidSettings 인스턴스 concat 함수 테스트', () => {
    const result = monoidSettings.concat(workspaceSettings, userSettings);

    expect(isSome(result.fontFamily)).toBeTruthy();
    expect(result.fontFamily).toMatchObject(some('Fira Code'));

    expect(isSome(result.fontSize)).toBeTruthy();
    expect(result.fontSize).toMatchObject(some(12));

    expect(isSome(result.maxColumn)).toBeTruthy();
    expect(result.maxColumn).toMatchObject(some(80));
  });
});

monoidSettings 인스턴스의 concat 함수에 왼쪽에 기존 설정, 오른쪽에 사용자 설정을 넘겨 병합합니다. monoidSettings 인스턴스의 필드들은 getLastMonoid로 만들어진 인스턴스로 이루어져 있기 때문에 오른쪽으로 전달된 userSettings 값 또는 None이 아닌 값으로 결과가 병합되었는지 확인합니다.


Written by@Minsu Kim
Software Engineer at KakaoPay Corp.