fp-ts로 Typescript 함수형 프로그래밍 시작하기 9 (Either vs Validation)

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

문제

계정을 등록하기 위한 웹 양식을 구현해야 합니다. 양식에는 usernamepassword 두 가지 필드가 있으며 아래의 유효성 검사 규칙을 따라야 합니다.

  • username은 비워둘 수 없습니다.
  • username를 포함할 수 없습니다.
  • password는 6자 이상이어야 합니다.
  • password는 적어도 하나의 대문자를 가져야 합니다.
  • password는 최소 하나의 숫자를 가져야 합니다.

Either

Either<E, A> 타입은 E 타입의 오류로 실패하거나 A 타입의 값으로 성공할 수 있는 계산을 나타내므로 유효성 검사 규칙을 구현하기에 좋은 후보입니다.

예를 들어 각 비밀번호 규칙을 인코딩해 보겠습니다.

import type { Either } from 'fp-ts/lib/Either';
import { left, right } from 'fp-ts/lib/Either';

const minLength = (s: string): Either<string, string> =>
  s.length >= 6 ? right(s) : left('at least 6 characters');

const oneCapital = (s: string): Either<string, string> =>
  /[A-Z]/g.test(s) ? right(s) : left('at least one capital letter');

const oneNumber = (s: string): Either<string, string> =>
  /[0-9]/g.test(s) ? right(s) : left('at least one number');

우리는 모든 규칙을 chain을 이용해 묶을 수 있습니다.

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

import type { Either } from 'fp-ts/lib/Either';
import { chain } from 'fp-ts/lib/Either';
import { pipe } from 'fp-ts/lib/function';

export const chainValidatePassword = (s: string): Either<string, string> =>
  pipe(minLength(s), chain(oneCapital), chain(oneNumber));

우리는 Either를 사용하고 있기 때문에 먼저 실패하는 것만 확인합니다. 즉, 앞의 검증에 실패하면 뒤의 검증을 하지 않음으로 하나의 오류만 발생합니다.

console.log(validatePassword('ab'));
// => left("at least 6 characters")

console.log(validatePassword('abcdef'));
// => left("at least one capital letter")

console.log(validatePassword('Abcdef'));
// => left("at least one number")

그러나 이것은 나쁜 UX로 이어질 수 있으며 이런 모든 오류를 동시에 보고하는 것이 좋습니다.

여기에서 Validation 추상화가 도움이 될 수 있습니다.

Validation

ValidationEither<E, A>와 매우 유사하며 E 타입의 오류로 실패하거나 A 타입의 값으로 성공할 수 있는 계산을 나타내지만 Either의 일반적인 계산과는 달리 여러 실패를 수집할 수 있습니다.

이를 위해서는 E 타입의 두 값을 결합하는 방법을 Validation에게 알려야 합니다.

동일한 타입의 두 값을 결합하는 것이 Semigroup의 모든 것입니다.

예를 들어 오류를 비어있지 않은 배열에 압축 할 수 있습니다.

'fp-ts/lib/Either' 모듈은 Semigroup이 주어지면 Either에 대한 대체 Applicative 인스턴스를 반환하는 getValidation 함수를 제공합니다.

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

import { getSemigroup } from 'fp-ts/lib/NonEmptyArray';
import { getApplicativeValidation } from 'fp-ts/lib/Either';

const applicativeValidation = getApplicativeValidation(getSemigroup<string>());

그러나 applicativeValidation을 사용하려면 먼저 Either<NonEmptyArray<string>, string> 타입의 값을 반환하도록 모든 규칙을 재정의 해야 합니다.

번거롭게 이전 함수를 모두 다시 작성하는 대신 Either<E, A>를 반환하는 것을 Either<NonEmptyArray<E>, A>를 반환하도록 콤비네이터를 정의할 수 있습니다.

import type { NonEmptyArray } from 'fp-ts/lib/NonEmptyArray';
import { mapLeft } from 'fp-ts/lib/Either';

function lift<E, A>(
  check: (a: A) => Either<E, A>
): (a: A) => Either<NonEmptyArray<E>, A> {
  return a =>
    pipe(
      check(a),
      mapLeft(a => [a])
    );
}

const minLengthV = lift(minLength);
const oneCapitalV = lift(oneCapital);
const oneNumberV = lift(oneNumber);

모두 합쳐서 n개의 작업을 왼쪽에서 오른쪽으로 수행하여 결과 튜플을 반환하는 sequenceT 헬퍼 함수를 사용할 것입니다.

import { sequenceT } from 'fp-ts/lib/Apply';
import { map } from 'fp-ts/lib/Either';

function validatePassword(s: string): Either<NonEmptyArray<string>, string> {
  return pipe(
    sequenceT(getApplicativeValidation(getSemigroup<string>()))(
      minLengthV(s),
      oneCapitalV(s),
      oneNumberV(s)
    ),
    map(() => s)
  );
}
console.log(validatePassword('ab'));
// => left(["at least 6 characters", "at least one capital letter", "at least one number"])

부록

참고: sequenceT 헬퍼 함수는 다양한 타입의 작업을 처리 할 수 ​​있습니다.

interface Person {
  name: string;
  age: number;
}

// Person 생성자
const toPerson = ([name, age]: [string, number]): Person => ({
  name,
  age,
});

const validateName = (s: string): Either<NonEmptyArray<string>, string> =>
  s.length === 0 ? left(['Invalid name']) : right(s);

const validateAge = (s: string): Either<NonEmptyArray<string>, number> =>
  isNaN(+s) ? left(['Invalid age']) : right(+s);

function validatePerson(
  name: string,
  age: string
): Either<NonEmptyArray<string>, Person> {
  return pipe(
    sequenceT(applicativeValidation)(validateName(name), validateAge(age)),
    map(toPerson)
  );
}

Written by@Minsu Kim
Software Engineer at KakaoPay Corp.