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

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

fp-ts 시작하기 (IO)

fp-ts에서 동기적인 이펙트 있는 계산은 기본적으로 () => A 시그니처를 갖는 썽크IO 타입으로 표현합니다.

interface IO<A> {
  (): A;
}

참고: IO절대 실패하지 않는 계산을 표현합니다.

이러한 계산의 예시는 아래와 같습니다.

  • localStorage에 읽기 / 쓰기
  • 현재 시간을 얻기
  • 콘솔에 쓰기
  • 임의의 숫자를 얻기

예시 (localStorage에 읽기 / 쓰기)

import type { Option } from 'fp-ts/lib/Option';
import { fromNullable } from 'fp-ts/lib/Option';

const getItem = (key: string): IO<Option<string>> => () =>
  fromNullable(localStorage.getItem(key));

const setItem = (key: string, value: string): IO<void> => () =>
  localStorage.setItem(key, value);

예시 (현재 시간을 얻기)

const now: IO<number> = () => new Date().getTime();

예시 (콘솔에 쓰기)

const log = (s: unknown): IO<void> => () => console.log(s);

예시 (임의의 숫자를 얻기)

const random: IO<number> = () => Math.random();

IO 타입은 Monad 인스턴스를 허용하므로 map을 사용할 수 있습니다.

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

import { map } from 'fp-ts/lib/IO';

/** 무작위의 boolean을 반환한다. */
const randomBool: IO<boolean> = map(n => n < 0.5)(random);

또한 chain 연산을 사용할 수 있습니다.

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

import { chain } from 'fp-ts/lib/IO';

/** 무작위의 boolean을 콘솔에 출력한다. */
const program: IO<void> = chain(log)(randomBool);

program();

참고: program()을 호출할 때까지 아무 일도 일어나지 않습니다.

그 이유는 program은 이펙트 있는 계산을 표현하는 이기 때문에, 어떤 사이드 이펙트를 실행하기 위해서는 ”IO 액션을 실행”해야 하기 때문입니다.

IO 액션은 값일 뿐이므로 Monoid와 같은 유용한 추상화를 사용하여 처리할 수 있습니다.

예시 (Dungeons and Dragons)

import type { IO } from 'fp-ts/lib/IO';
import type { Monoid } from 'fp-ts/lib/Monoid';
import { log } from 'fp-ts/lib/Console';
import { chain } from 'fp-ts/lib/IO';
import { concatAll } from 'fp-ts/lib/Monoid';
import { MonoidSum } from 'fp-ts/lib/number';
import { randomInt } from 'fp-ts/lib/Random';
import { getApplicativeMonoid } from 'fp-ts/lib/Applicative';

type Die = IO<number>;

const monoidDie: Monoid<Die> = getApplicativeMonoid(Applicative)(MonoidSum);

/** 주사위를 굴린 결과의 합을 반환합니다. */
const roll: (dice: Array<Die>) => IO<number> = concatAll(monoidDie);

const D4: Die = randomInt(1, 4);
const D10: Die = randomInt(1, 10);
const D20: Die = randomInt(1, 20);

const dice = [D4, D10, D20];

chain((result) => log(`Result is: ${result}`))(roll(dice))();
/*
Result is: 11
*/

또는 유용한 콤비네이터를 정의할 수 있습니다.

/** 디버깅을 위해 콘솔에 값을 기록한다. */
const withLogging = <A>(action: IO<A>): IO<A> =>
  chain<A, A>(a => map(() => a)(log(`Value is: ${a}`)))(action);

chain(result => log(`Result is: ${result}`))(roll(dice.map(withLogging)))();
/*
Value is: 4
Value is: 2
Value is: 13
Result is: 19
*/

에러 처리

실패할 수 있는 동기적인 이펙트 있는 계산을 표현하려면 어떻게 해야 할까요?

우리는 두 가지 이펙트가 필요합니다.

타입 생성자 이펙트 (해석)
IO<A> 비동기적인 이펙트 있는 계산
Either<E, A> 실패할 수 있는 계산

해결책은 EitherIO 내부에 두는 것입니다. 이것은 IOEither 타입으로 이어집니다.

interface IOEither<E, A> extends IO<Either<E, A>> {}

IOEither<E, A> 타입의 값을 “실행”할 때 Left가 있으면 E 타입의 오류로 인해 계산이 실패했음을 의미하고 그렇지 않으면 Right를 얻습니다. 이는 A 타입의 값으로 계산이 성공했음을 의미합니다.

예시 (파일 읽기)

fs.readFileSync가 예외를 발생시킬 수 있음므로 tryCatch 헬퍼를 사용하겠습니다.

tryCatch: <E, A>(f: () => A) => IOEither<E, A>

여기서 f는 오류(tryCatch에 의해 자동으로 포착 됨)를 던지거나 A 타입의 값을 반환하는 썽크입니다.

import type { IOEither } from 'fp-ts/lib/IOEither';
import { toError } from 'fp-ts/lib/Either';
import { tryCatch } from 'fp-ts/lib/IOEither';
import * as fs from 'fs';

export const readFileSync = (path: string): IOEither<Error, string> =>
  tryCatch(() => fs.readFileSync(path, 'utf8'), toError);

readFileSync('foo')();
// => left(Error: ENOENT: no such file or directory, open 'foo')
readFileSync(__filename)();
// => right(...)

Lifiting

fp-ts/lib/IOEither 모듈은 IOEither 타입의 값을 생성 할 수 있는 다른 헬퍼 함수를 제공하며, 이를 집합적으로 lifting 함수라고 합니다.

요약은 아래와 같습니다.

시작 값 lifting 함수
IO<E> leftIO: <E, A>(ml: IO<E>) => IOEither<E, A>
E left: <E, A>(e: E) => IOEither<E, A>
Either<E, A> fromEither: <E, A>(ma: Either<E, A>) => IOEither<E, A>
A right: <E, A>(a: A) => IOEither<E, A>
IO<A> rightIO: <E, A>(ma: IO<A>) => IOEither<E, A>

예시 (임의의 파일을 읽기)

3개 파일 1.txt, 2.txt, 3.txt 중 하나의 내용을 무작위로 읽는다고 가정해 보겠습니다.

randomInt: (low: number, high: number) => IO<number> 함수는 닫힌 범위 [low, high]에 균일하게 분포된 임의의 정수를 반환합니다.

import { randomInt } from 'fp-ts/lib/Random';

위에 정의된 readFileSync 함수로 randomInt를 연결할 수 있습니다.

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

import { chain } from 'fp-ts/lib/IOEither';

const randomFile = chain(n => readFileSync(`${__dirname}/${n}.txt`))(
  randomInt(1, 3) // 정적 오류
);

타입이 맞지 않습니다. randomIntIO 컨텍스트에서 실행되고 readFileSyncIOEither 컨텍스트에서 실행됩니다.

그러나 rightIO를 사용하여 randomIntIOEither 컨텍스트로 들어 올릴 수 있습니다.

const randomFile = chain(n => readFileSync(`${__dirname}/${n}.txt`))(
  rightIO(randomInt(1, 3))
);

Written by@Minsu Kim
Software Engineer at KakaoPay Corp.