본문 바로가기
Front-End/TypeScript

[TS] Typescript 에서 pipe 연산자 구현하기

by 흐암졸령 2023. 7. 25.
반응형

pipe 함수를 왜 사용해야 하는가

만약 다음과 같은 코드를 작성한다고 해봅시다.

const n = -19;
const result = Math.floor(Math.sqrt(Math.abs(n))));

작성하는 사람도, 보는 사람도 알기 어려운 코드입니다. 이를 pipe 함수를 사용해서 나타내면 다음과 같습니다.

const n = -19;
const result = pipe(n, Math.abs, Math.sqrt, Math.floor);

이처럼 pipe 함수를 사용하면 코드의 흐름을 분명하게 할 수 있습니다.

 

Javascript 에서 pipe 함수

function pipe(arg, firstFn, ...fns) {
  return fns.reduce((acc, fn) => fn(acc), firstFn(arg));
}

위처럼 reduce를 사용할 수도 있고, for loop를 사용해서 만들 수도 있다. 기능은 하겠지만, typescript 에서 타입추론은 되지 않는다. 연결된 함수 사이에 타입이 맞지 않아도 오류가 뜨지 않고, 런타임에서 에러를 맞이하게 된다. 그렇다면 Typescript 에서는 어떻게 하여 에러를 방지할 수 있는지 알아보자.

 

Typescript 에서 pipe 함수

 

export const pipe = <FirstFn extends AnyFunc, F extends AnyFunc[]>(
    arg: Parameters<FirstFn>[0],
    firstFn: FirstFn,
    ...fns: MatchFirstFnWithPipeArgs<FirstFn, PipeArgs<F> extends F ? F : PipeArgs<F>>
): LastFnReturnType<F, ReturnType<FirstFn>> => {
    return (fns as AnyFunc[]).reduce((acc, fn) => fn(acc), firstFn(arg));
}

먼저 전체적인 구조를 보자. 제네릭으로 입력을 받는 것은 크게 두 가지

  1. 첫 번째 함수 타입
  2. 두 번째부터의 함수 타입 리스트

이다. 검증을 하는 부분은 "...fns: MatchFirstFnWithPipeArgs<FirstFn, PipeArgs<F> extends F ? F : PipeArgs<F>>" 으로 MatchFirstFnWithPipeArgs을 통해 첫 번째 함수의 반환 타입이 두 번째 함수의 입력타입과 같은지 확인한다. 그리고 두 번째부터는 PipeArgs을 통해서 확인한다. 

 

 

pipe 함수의 반환 타입

type AnyFunc = (...arg: any) => any;

type LastFnReturnType<F extends Array<AnyFunc>, Else = never> =
    F extends [...any[], (...arg: any) => infer R]
        ? R
        : Else;

그리고 pipe 함수의 반환 타입은 마지막 함수의 반환 타입이다. 이는 LastFnReturnType 으로 추론할 수 있다.

 

 

pipe - 첫 함수의 반환 타입과 두 번째 함수의 입력 타입 검증

type FirstFnReturnType<F extends AnyFunc> =
    F extends (...arg: any) => infer R
        ? R
        : never;

type MatchFirstFnWithPipeArgs<F extends AnyFunc, P extends AnyFunc[]> =
    P extends [(arg: FirstFnReturnType<F>) => any, ...any[]]
        ? P
        : never;

FirstFnReturnType의 이름은 FirstFn이긴 하지만, 함수의 리턴 타입을 반환한는 것이다. 그래서 MatchFirstFnWithPipeArgs 에서 첫 함수의 리턴 타입이 두 번째 함수의 입력 타입으로 들어갔을 때 extends가 되는지 확인한다. 그리고 만족하지 않으면 never 타입으로 오류를 발생시킽다.

 

pipe - 두 번째 함수부터의 타입 검증

type PipeArgs<F extends AnyFunc[], Acc extends AnyFunc[] = []> =
    F extends [(...args: infer A) => infer B]
        ? [...Acc, (...args: A) => B]
        : F extends [(...args: infer A) => any, ...infer Tail]
            ? Tail extends [(arg: infer B) => any, ...any[]]
                ? PipeArgs<Tail, [...Acc, (...args: A) => B]>
                : Acc
            : Acc;

PipeArgs 에서는 재귀를 사용하여서 타입을 하나씩 검증한다. "F extends [(...args: infer A) => infer B]"의 조건 (F의 배열 길이가 1)을 만족한다면, Acc 뒤에 F를 붙여서 반환한다. 만약 그렇지 않다면, F의 두 번째 부터를 Tail이라 하고, Tail의 첫 번째와 F의 첫 번째의 타입을 비교한다. 그렇게 타입이 extends 된다면 Acc 뒤에는 F 첫 함수를 붙이고, F는 첫 함수를 제외한 Tail 함수 리스트로 다시 반복한다. 타입이 만족되지 않는다면 바로 Acc를 반환하고, pipe 함수에서 타입 오류가 생길 것이다.

 

사용 방법

아래는 제네릭으로 함수 타입을 명시적으로 적었지만, 굳이 적지 않아도 알아서 타입추론이 된다.

test('여러 함수를 pipe 로 연결할 수 있다.', () => {
		type NumberFunc = (a: number) => number;
        
        const add = (a: number) => a + 10;
        const multiply = (a: number) => a * 2;
        const divide = (a: number) => a / 2;

        const result = pipe<NumberFunc, [NumberFunc, NumberFunc]>(10, add, multiply, divide);

        expect(result).toBe(20);
    })

 

Reference

 

How to use advanced Typescript to define a `pipe` function

Typescript is awesome but some functionnal libraries have limited implementation for Typescript...

dev.to

대부분 위 코드를 참고하였고, 처음과 마지막 타입을 제외하고 중간 함수 타입추론에서 문제가 있어 수정한 내용을 포스팅하였습니다.

반응형

'Front-End > TypeScript' 카테고리의 다른 글

[TS] jest (vite, typescript) 설정하기  (0) 2023.03.14
[TS] Vite + TS 절대경로 설정하기  (0) 2023.03.08

댓글