fp-ts로 Typescript 함수형 프로그래밍 시작하기 0

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

fp-ts를 이용한 비함수형 코드와의 상호 운용성

때로는 함수형 프로그래밍 스타일로 작성되지 않은 코드와 상호 운영해야 하는 경우가 있습니다. 그러한 상황에서 fp-ts를 이용해 해결하는 방법을 살펴보겠습니다.

Sentinels

  • 유스 케이스: 실패할 수 있고 코드 도메인의 특수한 값을 반환할 수 있는 API
  • 예시: Array.prototype.findIndex
  • 해결 방법: Option
import { Option, none, some } from 'fp-ts/Option';

function findIndex<A>(
  as: Array<A>,
  predicate: (a: A) => boolean
): Option<number> {
  const index = as.findIndex(predicate);
  return index === -1 ? none : some(index);
}

findIndex 함수 테스트하기

Array.prototype.findIndex는 값을 찾지 못했을 경우 -1이라는 특수한 값을 반환합니다.

Array.prototype.findIndex 함수를 Option 타입을 이용해 fp-ts와 함께 사용할 수 있도록 변경했다. -1이 반환될 경우 none을 반환하고 그렇지 않은 경우 some을 반환하도록 작성했습니다.

작성한 findIndex 함수를 테스트하기 위한 패키지는 아래와 같습니다.

  • findIndex 함수를 테스트하기 위한 패키지
import * as N from 'fp-ts/number';
import { isSome, none, some, getEq, Option, isNone } from 'fp-ts/lib/Option';
import { findIndex } from '../findIndex';
  • findIndex 함수를 테스트하는 코드

테스트코드에서는 findIndexpredicate의 조건에 맞는 값을 찾았을 경우 isSome 함수를 이용해 some인지 확인한 후 해당 값의 인덱스를 some으로 감싸 반환된 값과 같은지 확인합니다.

조건에 맞는 값을 찾지 못했을 경우 isNone 함수를 이용해 none인지 확인한 후 none과 같은지 확인합니다.

describe('findIndex함수 테스트 (Sentinels)', () => {
  let array: number[] = [1, 2, 3, 5];
  let result: Option<number>;
  const E = getEq(N.Eq);
  it('findIndex함수가 존재하는 값을 찾았을 경우', () => {
    result = findIndex(array, a => a === 1);
    expect(isSome(result)).toBeTruthy();
    expect(E.equals(result, some(0))).toBeTruthy();
  });
  it('findIndex함수가 값을 찾지 못했을 경우', () => {
    result = findIndex(array, a => a === 4);
    expect(isNone(result)).toBeTruthy();
    expect(E.equals(result, none)).toBeTruthy();
  });
});

undefinednull

  • 유스 케이스: 실패하고 undefinednull을 반환할 수 있는 API
  • 예시: Array.prototype.find
  • 해결 방법: Option, fromNullable
import { Option, fromNullable } from 'fp-ts/Option';

export function find<A>(as: Array<A>, predicate: (a: A) => boolean): Option<A> {
  return fromNullable(as.find(predicate));
}

find함수 테스트하기

Array.prototype에 있는 find 함수는 null이나 undefined와 같은 Falsy값을 반환할 수 있습니다.

fromNullable 함수를 이용해 find 함수를 감싸 Option 타입을 반환하는 함수로 만들었습니다.

작성한 find 함수를 테스트하기 위한 패키지는 아래와 같습니다.

  • find 함수를 테스트하기 위한 패키지
import * as N from 'fp-ts/number';
import { isSome, none, some, getEq, Option, isNone } from 'fp-ts/lib/Option';
import { find } from '../find';
  • find 함수를 테스트하는 코드

새로 작성한 find 함수는 값을 찾았을 경우 some 태그를 갖는 값을 반환합니다. 값을 찾았을 경우에는 isSome 메서드가 true를 반환하는지 확인하고 값이 some으로 감싸진 값과 같은지 확인합니다.

반면에 값을 찾지 못했을 경우 none 태그를 갖는 값을 반환합니다. 값을 찾지 못했을 경우에는 isNone 메서드가 true를 반환하는지 확인하고 값이 none과 같은지 확인합니다.

describe('find함수 테스트 (undefined와 null)', () => {
  let array: number[] = [1, 2, 3, 5];
  let result: Option<number>;
  const E = getEq(N.Eq);
  it('find함수가 존재하는 값을 찾았을 경우', () => {
    result = find(array, a => a === 1);
    expect(isSome(result)).toBeTruthy();
    expect(E.equals(result, some(1))).toBeTruthy();
  });
  it('find함수가 값을 찾지 못했을 경우', () => {
    result = find(array, a => a === 4);
    expect(isNone(result)).toBeTruthy();
    expect(E.equals(result, none)).toBeTruthy();
  });
});

예외

  • 유즈 케이스: throw될 수 있는 API
  • 예시: JSON.parse
  • 해결 방법: Eiter, tryCatch
import { Either, tryCatch } from 'fp-ts/Either';

function parse(s: string): Either<Error, unknown> {
  return tryCatch(
    () => JSON.parse(s),
    reason => new Error(String(reason))
  );
}

parse 함수 테스트하기

JSON.parse 함수는 인자로 잘못된 문자열이 전달될 경우 예외를 발생시킵니다. fp-ts의 tryCatch를 이용하면 fp-ts와 함께 사용할 수 있도록 Either 타입을 반환시킬 수 있습니다.

작성한 parse 함수를 테스트하기 위한 패키지는 아래와 같습니다.

  • parse 함수를 테스트하기 위한 패키지
import { isLeft, isRight, getOrElse } from 'fp-ts/Either';
import { parse } from '../parse';
  • parse 함수를 테스트하는 코드

parse 함수가 성공하는 값과 실패하는 값을 준비한 후 parse 함수가 정상적으로 실행되는 경우 Either 패키지에 있는 isRight 함수를 사용해 정상적으로 코드가 실행되었는지 확인합니다. 그 후 getOrElse 함수를 이용해 Right에서 값을 꺼내 기대한 객체와 값이 같은지 확인합니다.

반면에 실패했을 때는 isLeft 함수를 이용해 Left가 반환되었는지 확인합니다. getOrElse 함수의 인자로 전달된 함수가 반환한 값과 기대하는 값이 같은지 확인합니다.

describe('parse함수 테스트 (예외)', () => {
  const success = '{"a": 1, "b": 2}';
  const fail = '{"a": 1, "b"}';
  let result;
  it('parse함수가 정상적으로 실행됐을 경우', () => {
    result = parse(success);
    expect(isRight(result)).toBeTruthy();
    expect(getOrElse(() => ({ error: true }))(result)).toMatchObject({
      a: 1,
      b: 2,
    });
  });
  it('parse함수 실행 중 예외가 발생했을 경우', () => {
    result = parse(fail);
    expect(isLeft(result)).toBeTruthy();
    expect(getOrElse(() => ({ a: 1 }))(result)).toMatchObject({
      a: 1,
    });
  });
});

무작위 값

  • 유즈 케이스: 비결정론적인 값을 반환하는 API
  • 예시: Math.random
  • 해결 방법: IO
import { IO } from 'fp-ts/IO';

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

random 함수 테스트하기

Math.random 함수는 절대 실패하지 않지만, 비결정론적인 값을 반환합니다. 따라서 fp-ts와 함께 사용하기 위해서 IO 타입으로 감싸 값을 반환하면 됩니다.

작성한 random 함수를 테스트하기 위한 패키지는 아래와 같습니다.

  • random 함수를 테스트하기 위한 패키지
import { random } from '../random';
  • random 함수를 테스트하는 코드

Math.random은 앞에서 설명했듯이 비결정론적인 값을 반환합니다. 따라서 값을 mocking하도록 도와주는 jest.fn 함수가 반환한 jest.Mock 인스턴스의 mockReturnValue 함수를 이용해 고정적으로 값을 반환할 수 있게 해줍니다. 그 후 mocking된 값이 작성한 random 함수가 반환하는 값과 같은지 확인합니다.

describe('random함수 테스트 (랜덤)', () => {
  let result;
  it('Math.random함수를 mocking해 테스트하기', () => {
    Math.random = jest.fn().mockReturnValue(0.5);
    result = random();
    expect(result).toBe(0.5);
  });
});

동기 부수 효과

  • 유즈 케이스: 전역 상태를 읽거나 쓰는 API
  • 예시: localStorage.getItem
  • 해결 방법: IO
import { Option, fromNullable } from 'fp-ts/Option';
import { IO } from 'fp-ts/IO';

function getItem(key: string): IO<Option<string>> {
  return () => fromNullable(localStorage.getItem(key));
}

getItem 함수 테스트하기

localStorage.getItem 함수는 값을 찾지 못하면 null을 반환하는 함수입니다. 따라서 fromNullable을 이용해 값을 찾지 못하면 none을 반환하고 값을 찾았을 경우 some을 반환하도록 구현할 수 있습니다.

작성한 getItem 함수를 테스트하기 위한 패키지는 아래와 같습니다.

  • getItem 함수를 테스트하기 위한 패키지
import * as S from 'fp-ts/string';
import { isSome, isNone, none, some, getEq, Option } from 'fp-ts/Option';
import { getItem } from '../getItem';
  • getItem 함수를 테스트하는 코드

localStorage.getItem 함수는 브라우저상에서 사용할 수 있는 함수이므로 jest.fn을 이용해 원하는 key가 들어왔을 경우에만 값을 반환하도록 mocking해 줍니다. 여기서 주의해야 할 점은 구현한 getItem 함수는 함수를 반환하는 함수이므로 함수 호출 연산자를 한 번 더 사용해야 합니다.

getItem 함수가 반환한 함수가 성공적으로 값을 가져왔을 경우 some인지 확인한 후 값이 같은지 비교해주고 값을 찾지 못해 none을 반환할 경우 none인지 확인합니다.

describe('getItem함수 테스트 (동기 부수 효과)', () => {
  window.localStorage.__proto__.getItem = jest.fn(key => {
    if (key === 'success') return 'success';
    return null;
  });
  let result: Option<string>;
  const E = getEq(S.Eq);
  it('getItem함수가 값을 정상적으로 가져온 경우', () => {
    result = getItem('success')();
    expect(isSome(result)).toBeTruthy();
    expect(E.equals(result, some('success'))).toBeTruthy();
  });
  it('getItem함수가 값을 가져오지 못했을 경우', () => {
    result = getItem('fail')();
    expect(isNone(result)).toBeTruthy();
    expect(E.equals(result, none)).toBeTruthy();
  });
});
  • 유즈 케이스: 전역 상태를 읽고 / 쓰고 throw할 수있는 API
  • 예시: readFileSync
  • 해결 방법: IOEither, tryCatch
import * as fs from 'fs';
import { IOEither, tryCatch } from 'fp-ts/IOEither';

function readFileSync(path: string): IOEither<Error, string> {
  return tryCatch(
    () => fs.readFileSync(path, 'utf8'),
    reason => new Error(String(reason))
  );
}

readFileSync 함수 테스트하기

fsreadFileSync 함수는 파일을 동기적으로 읽어 문자열 값을 반환하는 함수입니다. 매개변수로 전달된 경로에 파일이 없으면 예외를 발생시킵니다. IOEither 타입과 tryCatch 함수를 이용해 readFileSync 함수를 개선할 수 있습니다. 파일을 정상적으로 읽었을 경우 Rightstring을 반환하고 예외가 발생할 경우 LeftError를 반환합니다.

작성한 readFileSync 함수를 테스트하기 위한 패키지는 아래와 같습니다.

  • readFileSync 함수를 테스트하기 위한 패키지
import * as fs from 'fs';
import { Either, isRight, getOrElse, isLeft } from 'fp-ts/lib/Either';
import { readFileSync } from '../readFileSync';
  • readFileSync 함수를 테스트하는 코드

fsreadFileSync 함수는 readonly이므로 spyOn을 이용해 값을 mocking해 줍니다. 원하는 경로가 전달되었을 때만 문자열을 반환하고 그렇지 않은 경우 예외를 발생시킵니다. 정상적으로 readFileSync 함수가 실행되었을 경우 Right 타입인지 확인한 후 getOrElse로 반환된 결과가 mocking된 문자열인지 확인합니다. 예외가 발생했을 경우 Left 타입인지 확인한 후 getOrElse로 반환된 결과가 getOrElse 함수가 반환한 문자열인지 확인합니다.

describe('readFileSync함수 테스트 (동기 부수 효과)', () => {
  jest.spyOn(fs, 'readFileSync').mockImplementation(path => {
    if (path === 'success.txt') return 'success';
    throw new Error(`${path} is not found.`);
  });
  let result: Either<Error, string>;
  it('readFileSync가 정상적으로 값을 가져왔을 경우', () => {
    result = readFileSync('success.txt')();
    expect(isRight(result)).toBeTruthy();
    expect(getOrElse(() => 'fail')(result)).toBe('success');
  });
  it('readFileSync함수 실행 중 예외가 발생했을 경우', () => {
    result = readFileSync('fail.txt')();
    expect(isLeft(result)).toBeTruthy();
    expect(getOrElse(() => 'fail')(result)).toBe('fail');
  });
});

비동기 부수 효과

  • 유즈 케이스: 비동기 계산을 수행하는 API
  • 예시: 표준 입력으로부터 읽을 때
  • 해결 방법: Task
import { createInterface } from 'readline';
import { Task } from 'fp-ts/Task';

const read: Task<string> = () =>
  new Promise<string>(resolve => {
    const rl = createInterface({
      input: process.stdin,
      output: process.stdout,
    });
    rl.question('', answer => {
      rl.close();
      resolve(answer);
    });
  });

read 함수 테스트하기

Task 타입은 값을 반환하고 절대로 실패하지 않는 비동기 작업을 나타낼 때 사용합니다. read 함수를 테스트하기 위해서는 readlinecreateInterface 함수를 mocking해야 합니다. createInterface 함수의 qeustion 함수와 close 함수를 아래처럼 mocking 합니다.

  • read 함수를 테스트하기 위한 createInterface 함수 mocking

__mocks__/readline.js

module.exports = {
  createInterface: jest.fn().mockReturnValue({
    question: jest.fn().mockImplementationOnce((questionText, cb) => {
      cb('success');
    }),
    close: () => undefined,
  }),
};

작성한 read 함수를 테스트하기 위한 패키지는 아래와 같습니다.

  • read 함수를 테스트하기 위한 패키지
import { read } from '../read';
  • read 함수를 테스트하는 코드

jest.mock 함수를 호출해 readline 패키지를 mocking합니다. read 함수는 절대로 실패하지 않으며 비동기 작업을 처리하므로 async, await구문을 이용해 read 함수를 호출해 값을 얻어 mocking한 값과 같은지 확인합니다.

jest.mock('readline');

describe('read함수 테스트 (비동기 부수 효과)', () => {
  it('read가 정상적으로 값을 읽었을 경우', async () => {
    const result = await read();
    expect(result).toBe('success');
  });
});
  • 유즈 케이스: 비동기 계산을 수행하고 거부될 수 있는 API
  • 예시: 표준 fetch
  • 해결 방법: TaskEither, tryCatch
import { TaskEither, tryCatch } from 'fp-ts/TaskEither';

function get(url: string): TaskEither<Error, string> {
  return tryCatch(
    () => fetch(url).then(res => res.text()),
    reason => new Error(String(reason))
  );
}

get 함수 테스트하기

fetch 함수는 비동기 작업을 처리하며 예외를 발생시킬 수 있습니다. fetch 함수를 개선하기 위해 TaskEither 타입을 이용해 get 함수를 작성했습니다.

작성한 get 함수를 테스트하기 위한 패키지는 아래와 같습니다.

  • get 함수를 테스트하기 위한 패키지
import { Either, getOrElse, isLeft, isRight } from 'fp-ts/lib/Either';
import { get } from '../get';
  • get 함수를 테스트하는 코드

fetch 함수는 Promise<Response>를 반환하며 get 함수 내부에서 사용한 text 함수 또한 Promise를 반환합니다. jest.fn을 이용해 함수를 mocking하고 특정 문자열이 들어올 경우 Promise.resolve로 값을 반환해 줍니다. 원하는 문자열이 아닐 경우 Promise.reject로 거부합니다.

async, await 구문을 이용해 get 함수로부터 함수를 받아 호출시킵니다. 정상적으로 값을 가져온 경우 isRight 함수로 Right 타입인지 확인한 후 getOrElse 함수로 Right 안의 값을 가져오는지 확인합니다. 거부당한 경우 Left인지 확인한 후 getOrElse 함수에 전달된 값이 반환되었는지 확인합니다.

describe('get함수 테스트 (비동기 부수 효과)', () => {
  global.fetch = jest.fn(url => {
    if (url === 'https://success.com') {
      return Promise.resolve({
        text: () => Promise.resolve('success'),
      }) as Promise<Response>;
    }
    return Promise.reject('fail');
  });
  let result: Either<Error, string>;
  it('get함수가 정상적으로 값을 가져왔을 경우', async () => {
    result = await get('https://success.com')();
    expect(isRight(result)).toBeTruthy();
    expect(getOrElse(() => 'fail')(result)).toBe('success');
  });
  it('get함수 실행 중 예외가 발생했을 경우', async () => {
    result = await get('https://fail.com')();
    expect(isLeft(result)).toBeTruthy();
    expect(getOrElse(() => 'fail')(result)).toBe('fail');
  });
});

Written by@Minsu Kim
Software Engineer at Devsisters Corp.