fp-ts 로 데이터 검증하기 2 - 데이터의 적합성 확인
목차
지난 글에서
지난 글 에서는 다음과 같은 코드를 작성했다.
import * as O from "fp-ts/Option" ;
interface UserInput {
username : string ;
password : string ;
}
const has = ( key : PropertyKey ) => O . flatMap ( O . fromPredicate (( obj : any ) => key in obj));
const isUserInput = ( body : any ) : O . Option < UserInput > =>
pipe (body, O .of, has ( "username" ), has ( "password" ));
isUserInput
함수는 body
가 UserInput
타입인지 확인하는 함수이다.
즉, username
, password
가 있는지만 확인할 수 있다.
이번 글에서는 해당 속성값들이 유효한 값인지 확인하는 방법을 알아보자.
데이터의 적합성 확인
인터페이스 추가
들어가기 전에 먼저 좀더 유용하게 코드를 작성할 수 있도록 일부 인터페이스를 정의해놓자.
interface Body extends Record < string , string > {}
interface Username extends Body {
username : string ;
}
interface Password extends Body {
password : string ;
}
interface UserInput extends Username , Password {}
Body
는 문자열로 된 키와 값을 가지고 있는 객체이다.
(Record
타입은 객체의 키와 값의 타입을 지정하기 위한 타입으로, 그냥 객체라고 생각해주면 된다.)
Username
은 username
속성을, Password
는 password
속성을 가지고 있는 Body
이다.
마지막으로 UserInput
은 둘을 합쳐 username
, password
속성을 모두 가지고 있다.
fp-ts
를 사용하지 않는다면
먼저 username
값만 확인하는 함수를 작성해보자.
다음과 같은 조건을 상정해보자.
최소 길이 6
최대 길이 20
알파벳과 숫자로만 이루어짐
최대한 단순무식하게 코드를 작성하면 다음 같은 코드로도 충분히 검사는 할 수 있다.
const validateUsernameWithoutFunction = ({ username } : ) =>
username. length >= 6
&& username. length <= 20
&& / ^ [a-zA-Z0-9] +$ / . test (username);
그럼 이제 각각의 조건들을 함수로 만들어보자.
const minLength = ( n : number ) => ( s : string ) => s. length >= n;
const maxLength = ( n : number ) => ( s : string ) => s. length <= n;
const includes = ( r : RegExp ) => ( s : string ) => s. test (str);
const isAlphaNumeric = includes ( / ^ [a-zA-Z0-9] +$ / );
이 함수들로 username
의 유효성을 검사하는 함수를 만들어보자.
const validateUsernameWithoutFpTs = ({ username } : Username ) =>
minLength ( 6 )(username) && maxLength ( 20 )(username) && isAlphaNumeric (username);
fp-ts
적용
이제 fp-ts
를 적용해보자.
먼저 Option
부터 적용을 해보자.
Record.lookup
함수를 사용해보자.
해당 함수는 키가 존재하면 Option.some
을, 존재하지 않으면 Option.none
을 반환한다.
그리고 지난 글에서 설명했듯이 Option.filter
함수는 boolean
을 반환하는 함수(Predicate
)를 인자로 받아 해당 함수의 결과값에 따라 Some
또는 None
을 반환한다.
이 함수들을 이용하면 다음과 같이 작성할 수 있다.
const validateUsernameWithOption = ( body : Username ) => {
let username = R . lookup ( "username" )(body);
username = O . filter ( minLength ( 6 ))(username);
username = O . filter ( maxLength ( 20 ))(username);
username = O . filter (isAlphaNumeric)(username);
return O . isSome (username);
}
이제 pipe
함수를 적용해보자.
const validateUsernameWithPipe = ( username : Username ) =>
pipe (
username,
R . lookup ( "username" ),
O . filter ( minLength ( 6 )),
O . filter ( maxLength ( 20 )),
O . filter (isAlphaNumeric)
);
반복을 줄이자
보다시피 O.filter
를 여러번 사용하고 있다.
반복을 줄일 수 있는 함수를 만들어보자.
먼저 Predicate<T>
배열을 받는다.
그리고 해당 함수들을 O.filter
에 넣는다.
그리고 Option<T>
값을 각 함수들에 넣는다.
그리고 그 모든 결과 값을 취합하여 모두 Some
인지 확인한다.
const validateWithoutPipe = < T >( preds : Predicate < T >[]) => ( o : O . Option < T >) => {
const filters = preds. map ( O .filter);
const filtered = filters. map (( filter ) => filter (o));
const concated = filtered. reduce (( acc , curr ) => ( O . isSome (acc) && O . isSome (curr) ? acc : O .none));
return concated;
}
Array.map
물론 이 함수로도 충분하지만, 좀더 가독성이 좋게 만들어보자.
Array.prototype.map
함수는 fp-ts/Array.map
함수로 대체할 수 있다.
import * as A from "fp-ts/Array" ;
const validateWithMap =
< T >( preds : Predicate < T >[]) =>
( o : O . Option < T >) => {
const filtered = A . map (( filter : ( fa : O . Option < T >) => O . Option < T >) =>
filter (o)
)( A . map ( O .filter < T > )(preds));
const concated = filtered. reduce (( acc , curr ) =>
O . isSome (acc) && O . isSome (curr) ? acc : O .none
);
};
function.apply
또 (f) => f(a)
는 fp-ts/function.apply
로 대체할 수 있다.
import * as apply from "fp-ts/function" ;
const validateWithApply =
< T >( preds : Predicate < T >[]) =>
( o : O . Option < T >) => {
const filtered = A . map ( F . apply (o))( A . map ( O .filter < T > )(preds));
const concated = filtered. reduce (( acc , curr ) =>
O . isSome (acc) && O . isSome (curr) ? acc : O .none
);
};
Monoid.concatAll
그리고 reduce
도 fp-ts/Array.reduce
가 존재한다.
하지만 Monoid
의 concatAll
이용해서 더 간단하게 작성할 수 있다.
먼저 다음과 같은 함수를 만들어보자.
두 Option
이 모두 Some
이면 첫번째 Option
을 반환하고, 아니면 None
을 반환한다.
const concatOptionSome = < T >( x : O . Option < T >, y : O . Option < T >) =>
O . isSome (x) && O . isSome (y) ? x : O .none;
이제 Monoid.concatAll
을 통해 Option
배열을 하나의 Option
으로 만드는 함수를 만들자.
Monoid
를 따로 변수에 할당하지 않은 이유는 함수가 아니면 제네릭 타입을 쓸 수 없었기 때문이다.
const concatOptions = < T >( o : O . Option < T >) =>
Mono. concatAll < O . Option < T >>({
concat : ( x , y ) => ( O . isSome (x) && O . isSome (y) ? x : O .none),
empty: o,
});
그럼 다음과 같은 함수를 만들 수 있다.
const validateFilterWithoutPipe =
< T >( preds : Predicate < T >[]) =>
( o : O . Option < T >) =>
concatOptions (o)( A . map ( O .filter < T > )(preds). map ( F . apply (o)));
마지막으로 pipe
를 통해 좀더 읽기 쉽게 만들자.
const validate =
< T >( preds : Predicate < T >[]) =>
( o : O . Option < T >) =>
pipe (preds, A . map ( O .filter < T > ), A . map ( F . apply (o)), concatOptions (o));
이를 통해 최종적으로 다음과 같은 함수를 만들 수 있다.
const validateUsername = validate < string >([
minLength ( 6 ),
maxLength ( 20 ),
isAlphaNumeric,
]);
따로 빼서 검사하기
이제 validateUsername
함수를 이용해 username
을 검사해보자.
const validateUsernameButReturnString = ( username : Username ) =>
pipe (
username,
R . lookup ( "username" ),
validateUsername
);
그런데 보다시피, R.lookup("username")
과 validateUsername
를 지나면 Option<string>
이 된다.
하지만 그렇게 되면 password
를 검사할 수 없다.
이를 위해 username
을 따로 검사하는 함수를 만들자.
body
에서 username
을 추출해 validateUsername
함수에 넣어 Some
이 나오는지 확인한다.
const validateUsernameFromUserInput = ( body : Username ) : boolean =>
pipe (body, R . lookup ( "username" ), validateUsername, O .isSome);
이제 validateUsernameFromUserInput
함수를 이용해 username
만 검사해보자.
const validateUsernameOnly = ( body : Body ) =>
pipe (
body,
O .of,
has ( "username" ),
O . filter (validateUsernameFromUserInput),
has ( "password" ),
);
password
검사
이제 password
를 검사해보자.
조건은 다음과 같다.
최소 길이 8
최대 길이 20
알파벳과 숫자를 각각 하나 이상 포함
먼저 이 조건들을 함수로 만들어보자.
첫 두 개는 위에서 정의한 minLength
, maxLength
함수를 이용하면 된다.
마지막 조건도 위에서 정의한 includes
함수를 이용하면 된다.
const hasAlphaAndNumeric = includes ( / ^ (?= . *? \d )(?= . *? [a-zA-Z] ) . +$ / );
이제 이 함수들을 이용해 password
를 검사하는 함수를 만들어보자.
const validatePassword = validate < string >([
minLength ( 8 ),
maxLength ( 20 ),
hasAlphaAndNumeric,
]);
또 username
을 검사했던 것처럼 body
의 password
를 추출하여 따로 검사하는 함수를 만들자.
const validatePasswordFromUserInput = ( body : Password ) : boolean =>
pipe (body, R . lookup ( "password" ), validatePassword, O .isSome);
마찬가지로 O.filter
를 이용해 UserInput
을 검사하는 과정에 추가하자.
const validateUsernameAndPassword = ( body : Body ) =>
pipe (
body,
O .of,
has ( "username" ),
O . filter (validateUsernameFromUserInput),
has ( "password" ),
O . filter (validatePasswordFromUserInput),
);
그리고 R.lookup
에서 요소 검사가 되기 때문에 has
를 사용하지 않아도 된다.
const validateUsernameAndPassword = ( body : Body ) =>
pipe (
body,
O .of,
O . filter (validateUsernameFromUserInput),
O . filter (validatePasswordFromUserInput),
);
그럼 또 Option.filter(validate***FromUserInput)
을 반복하게 된다.
공통점을 찾아 함수를 만들어 보자.
const validateFromUserInputWithPipe =
( key : keyof Body & string ) =>
( validate : ( o : O . Option < string >) => O . Option < string >) =>
( body : Body ) : boolean =>
pipe (body, R . lookup (key), validate, O .isSome, O .filter)
그리고 공통적으로 사용된 filter
도 넣어주자.
const validateFromUserInputFilter =
< A extends Body , B extends A >( key : keyof B & string ) =>
( validate : ( o : O . Option < string >) => O . Option < string >) =>
O . filter < A , B >(( body : A ) : body is B =>
pipe (body, R . lookup (key), validate, O .isSome)
);
그런데 filter
안에 화살표 함수까지 넣는 것은 좀 지저분하다.
pipe
대신 flow
를 사용해보자.
const validateFromUserInput =
< A extends Body , B extends A >( key : keyof B & string ) =>
( validate : ( o : O . Option < string >) => O . Option < string >) =>
O . filter < A , B >(
flow <[ A ], O . Option < string >, O . Option < string >, boolean >(
R . lookup (key),
validate,
O .isSome
) as Refinement < A , B >
);
이제 다음과 같은 함수를 만들 수 있다.
const validateBodyIsUsername = validateFromUserInput < Body , Username >(
"username" ,
validateUsername
);
const validateUsernameIsUserInput = validateFromUserInput < Username , UserInput >(
"password" ,
validatePassword
);
이 함수들을 이용해 UserInput
을 검사하는 함수를 만들면 다음과 같다.
const validateUserInput = ( body : Body ) =>
pipe (body, O .of, validateBodyIsUsername, validateUsernameIsUserInput);
결과
최종적으로 다음과 같은 코드를 작성했다.
import * as R from "fp-ts/Record" ;
import * as O from "fp-ts/Option" ;
import * as A from "fp-ts/Array" ;
import * as Mono from "fp-ts/Monoid" ;
import { Refinement } from "fp-ts/Refinement" ;
import { Predicate } from "fp-ts/Predicate" ;
import { pipe, apply, flow, flip } from "fp-ts/function" ;
interface Body extends Record < string , string > {}
interface Username extends Body {
username : string ;
}
interface Password extends Body {
password : string ;
}
interface UserInput extends Username , Password {}
const minLength = ( n : number ) => ( s : string ) => s. length >= n;
const maxLength = ( n : number ) => ( s : string ) => s. length <= n;
const includes = ( s : RegExp ) => ( str : string ) => s. test (str);
const isAlphaNumeric = includes ( / ^ [a-zA-Z0-9] +$ / );
const concatOptions = < T >( o : O . Option < T >) =>
Mono. concatAll < O . Option < T >>({
concat : ( x , y ) => ( O . isSome (x) && O . isSome (y) ? x : O .none),
empty: o,
});
const validate =
< T >( preds : Predicate < T >[]) =>
( o : O . Option < T >) =>
pipe (preds, A . map ( O .filter < T > ), A . map ( apply (o)), concatOptions (o));
const validateUsername = validate < string >([
minLength ( 6 ),
maxLength ( 20 ),
isAlphaNumeric,
]);
const hasAlphaAndNumeric = includes ( / ^ (?= . *? \d )(?= . *? [a-zA-Z] ) . +$ / );
const validatePassword = validate < string >([
minLength ( 8 ),
maxLength ( 20 ),
hasAlphaAndNumeric,
]);
const validateFromUserInput = < A extends Body , B extends A >(
key : keyof B & string ,
validate : ( o : O . Option < string >) => O . Option < string >
) =>
O . filter < A , B >(
flow <[ A ], O . Option < string >, O . Option < string >, boolean >(
R . lookup (key),
validate,
O .isSome
) as Refinement < A , B >
);
const validateBodyIsUsername = validateFromUserInput < Body , Username >(
"username" ,
validateUsername
);
const validateUsernameIsUserInput = validateFromUserInput < Username , UserInput >(
"password" ,
validatePassword
);
const validateUserInput = ( body : Body ) =>
pipe (body, O .of, validateBodyIsUsername, validateUsernameIsUserInput);
테스트 코드는 다음과 같다.
const exampleUsernames : Record < string , Body > = {
valid: {
username: "username" ,
password: "password123" ,
}, // valid
hasNoUsername: {
password: "password123" ,
}, // wrong
hasNoPassword: {
username: "username" ,
}, // wrong
tooShortUsername: {
username: "user" ,
password: "password123" ,
}, // wrong
tooLongUsername: {
username: "usernameusernameusernameusername" ,
password: "password123" ,
}, // wrong
notAlphaNumericUsername: {
username: "username!" ,
password: "password123" ,
}, // wrong
tooShortPassword: {
username: "username" ,
password: "pass" ,
}, // wrong
tooLongPassword: {
username: "username" ,
password: "passwordpasswordpasswordpassword" ,
}, // wrong
alphaOnlyPassword: {
username: "username" ,
password: "password" ,
}, // wrong
numericOnlyPassword: {
username: "username" ,
password: "123456789" ,
}, // wrong
specialCharButValidPassword: {
username: "username" ,
password: "!password123" ,
}, // valid
};
console. log ( R . map ( flow (validateUserInput, O .toNullable))(exampleUsernames));
결과는 다음과 같다.
{
valid : { username : 'username' , password : 'password123' },
hasNoUsername : null ,
hasNoPassword : null ,
tooShortUsername : null ,
tooLongUsername : null ,
notAlphaNumericUsername : null ,
tooShortPassword : null ,
tooLongPassword : null ,
alphaOnlyPassword : null ,
numericOnlyPassword : null ,
specialCharButValidPassword : { username : 'username' , password : '!password123' }
}
잘 작동하는 것을 확인할 수 있다.