본문 바로가기
CS/Functional Programming

[FP] Type Safe Curry함수 만들기

by 그냥하는거지뭐~ 2025. 2. 1.

0. 들어가며: 함수형 프로그래밍과 Curry

요즘 함수형 프로그래밍(FP)을 공부하면서 "Currying"이라는 개념을 마주하게 되었다. 처음에는 단순히 복잡한 함수를 더 단순한 함수로 분해하는 기법으로만 보였는데, 깊이 들여다보니 Currying이 FP의 철학을 가장 잘 표현하는 기법 중 하나라는 생각이 들었다. 

 

📌 FP의 핵심 철학

FP는 다음과 같은 핵심 원칙들을 강조한다. 

- 함수를 일급 객체(First-Class Citizen)로 취급
- 불변성(Immutability)
- 순수 함수(Pure Function)
- 선언형 프로그래밍(Declarative Programming)
- 함수 합성(Composition), 모듈화(Modularity)
- 높은 수준의 추상화(Abstraction)

 

이 원칙들은 프로그램을 데이터의 흐름과 변환으로 모델링하는 FP의 철학을 반영한다.

 

📌 Currying이란?

Currying은 N개의 인자를 받는 함수를 각각 하나의 인자만 받는 N개의 함수로 변환하는 기법이다. (수학자 Haskell Curry의 이름에서 따옴)

 

반 함수와 Currying된 함수를 비교해보자.

// 일반적인 덧셈 함수
const add = (a: number, b: number) => a + b;
add(1, 2); // 3

// 커링된 덧셈 함수
const curriedAdd = (a: number) => (b: number) => a + b;
curriedAdd(1)(2); // 3

 

add 함수는 두 개의 인자를 한 번에 받는 반면, curriedAdd 함수는 먼저 a를 받고, 그 다음 b를 받는 두 개의 함수로 나눠진 구조이다. 

 

📌 Currying과 FP

함수를 일급 객체로 취급

Currying은 함수를 반환하는 함수를 만들어, 함수를 값처럼 다룬다. 이는 FP의 핵심인 함수의 일급 객체성을 잘 보여준다.

const multiply = (a: number) => (b: number) => a * b;
const double = multiply(2);
console.log(double(5)); // 10

여기서 double은 새로운 함수로 재사용 가능하다.

불변성, 순수 함수

: 원본 함수를 변경하지 않고 새로운 함수를 만들어내므로 불변성을 유지한다. 또, Currying된 함수는 각 단계에서 새로운 함수를 반환하므로 외부 상태를 변경하지 않는 순수 함수의 특성이 잘 드러난다. 

 

합성, 모듈화

Currying은 단일 인자(unary) 함수를 만들어내기 때문에, 여러 함수를 손쉽게 조합할 수 있다.

const curry = (f: Function) => (a: any) => (b: any) => f(a, b);
const add = (a: number, b: number) => a + b;
const curriedAdd = curry(add);

// 부분 적용(Partial Application)
const add3 = curriedAdd(3);

console.log([1, 2, 3, 4, 5].map(add3)); // [4, 5, 6, 7, 8]

add3은 기존 add 함수에서 3을 고정한 새로운 함수다. 이렇게 부분 적용이 쉬워지면, 코드의 재사용성과 가독성이 높아진다.

 

높은 수준의 추상화

Currying을 통해 일반적인 함수를 더 구체적인 용도로 쉽게 변환할 수 있다. 이를 통해 코드의 추상화 수준을 높이고, 반복되는 패턴을 효과적으로 관리할 수 있다.

const filterBy = (predicate: (value: number) => boolean) => (arr: number[]) => arr.filter(predicate);

const isEven = (n: number) => n % 2 === 0;
const filterEven = filterBy(isEven);

console.log(filterEven([1, 2, 3, 4, 5, 6])); // [2, 4, 6]

filterBy는 다양한 조건에 맞게 재사용할 수 있는 고차 함수로, 추상화를 잘 보여준다.

 

선언적 프로그래밍

Currying을 사용하면 "어떻게"(how) 보다는 "무엇을"(what) 표현하는 코드가 된다.

const greaterThan = (min: number) => (value: number) => value > min;
const filterGreaterThan10 = filterBy(greaterThan(10));

console.log(filterGreaterThan10([5, 12, 8, 20, 3])); // [12, 20]

여기서 greaterThan(10)은 선언적으로 조건을 표현하고, filterBy로 의도를 명확하게 전달한다.

 

 

처음 접한 상태에서는 낯설 수 있지만, Currying을 활용하면 더 선언적이고, 더 가독성이 좋은, 유지보수가 쉬운 코드를 작성할 수 있다.

여러 fp utils 라이브러리에서도 curry는 기본적으로 구현이 되어 있다. 

 

Rambda 라이브러리에서 코드를 구경해봤다.

타입이 없잖아?

 

타입스크립트 연습도 할 겸 type safe한 curry를 직접 구현해보자. 


1. 내가 만들 curry 함수

1.1. strict curry vs loose curry: 한 번에 여러 개의 인자를 받는 것을 허용해줄까 말까

🚩 Strict Curry vs Loose Curry
Strict Curry: 한 번에 딱 하나의 인자만 받도록 제한
Loose Curry: 한 번에 여러 인자를 받아도 동작하도록 허용

 

Haskell이라는 언어가 있다고 한다. 함수형 프로그래밍(FP)에 최적화된 프로그래밍 언어로, Haskell에서는 모든 함수가 기본적으로 커링된 형태이다. 즉, 여러 개의 인자를 받는 함수라도 실제로는 하나의 인자만 받는 함수의 체인으로 동작한다. 

-- add 함수: 두 개의 Int를 받아 더함
add :: Int -> Int -> Int
add x y = x + y

-- 커링된 형태로 호출 가능
add3 = add 3
result = add3 5  -- 결과: 8
  • add 3은 x = 3인 상태를 가진 새로운 함수를 반환한다.
  • add3 5는 두 번째 인자 5를 받아 최종 결과 8을 반환한다.
  • 즉, add 3 5라고 쓸 수 있지만, 내부적으로는 add(3) → (5)로 동작한다.
  • 즉, 항상 strict curry 형식이다. 

Haskell처럼 모든 함수가 하나의 인자만 받는 구조로 일관되면, 코드의 동작 방식을 예측하기 쉬워질 것이다. 함수의 형태가 단순하기 때문에 함수 합성(Composition)도 자연스럽게 이루어질 것이다. 

 

그런데, 여러 인자를 처리하려면 계속 중첩 호출해야 하므로 실용성 측면에서 불편함이 있다. Strict curry가 함수 합성을 더 명확하게 표현하지만, loose curry도 크게 문제를 일으키지 않으면서 더 편리하므로, 나는 Loose curry로 구현하겠다. 

const sum = (a: number, b: number, c: number) => a + b + c;
const curriedAdd = curry(sum);

curriedAdd(1)(2)(3); // ✅ 인자 1개 허용
curriedAdd(1, 2)(3); // ✅ 인자 2개도 허용
curriedAdd(1, 2, 3) // ✅ 한번에 모든 인자 전달하는것도 허용. 걍 다 허용

 

1.2. 🚀 본격적으로 curry 구현하기

curry 함수를 구현해보자! 인자를 누적해서 모으고, 인자의 개수가 충분하면 원래 함수를 실행하는 구조로 설계했다. 그렇지 않으면 재귀적으로 다시 커링된 함수를 반환한다.

type AnyFunc = (...args: any[]) => any;

const isComplete = (args: any[], expectedLength: number): boolean =>
  args.length >= expectedLength;

export function curry(
  fn: AnyFunc,
  ...collectedArgs: Partial<Parameters<Fn>>
):{
  return (...newArgs) => {
    const allArgs = [...collectedArgs, ...newArgs];

    return isComplete(allArgs, fn.length)
      ? fn(...allArgs)
      : curry(fn, ...allArgs);
  };
}
collectedArgs → 지금까지 누적된 인자들
newArgs → 새로 전달된 인자들
allArgs → 누적된 인자와 새 인자를 합친 전체 인자 배열
curry(sum)
    └── call(1)
                └── call(2)
                            └── call(3)
                                        └── 실행: sum(1, 2, 3) = 6
  • isComplete 함수로 현재까지 모은 인자 개수(allArgs.length)와 원래 함수(fn.length)를 비교해서 인자 개수가 충분한지 확인
  • true: 인자가 충분하면 fn 실행
  • false: 인자가 부족하면 curry 함수를 재귀적으로 호출해서 인자를 더 모음

 

1.3. 코드 Breakdown

① AnyFunc

type AnyFunc = (...args: any[]) => any;
  • 인자와 리턴값에 대한 제한이 없는 함수 타입이다. 
  • 편의를 위해 따로 분리했다. 

② Parameters<Fn>

type Example = (a: number, b: string, c: boolean) => void;
type Params = Parameters<Example>; // [number, string, boolean]
  • 제네릭 타입 Parameters<T>를 사용하면 함수의 인자 목록을 튜플 형태로 추출할 수 있다.
  • curry 함수에서 collectedArgs는 fn의 인자이므로, 이를 적용하면 좀더 정확하게 표현할 수 있다.

 Partial<Parameters<Fn>>

Partial<[number, string, boolean]>; 
// { 0?: number; 1?: string; 2?: boolean; }
  • Partial은 모든 요소를 optional하게 만들어서 부분적으로 인자를 적용할 수 있게 한다.
  • 예를 들어, 첫 번째 인자만 전달하거나, 첫 두 개의 인자만 전달하는 것이 가능하다.
  • curry 함수에서 collectedArgs는 함수의 파라미터 중 일부만 채워진 상태를 의미하므로, 이를 표현하기 위해 Partial<Parameters<Fn>>를 사용했다.

 


2. 제네릭으로 더 안전하게

2.1. Problem

사실 여기까지만 작성해도 동작은 잘 한다. 근데 타입 안전성이 부족하다. 

예를 들어, 아래와 같은 코드에서 타입스크립트가 오류를 잡아내야 하는데, 지금은 다 허용하고 있다.

const sum = (a: number, b: number, c: number) => a + b + c;
const curriedAdd = curry(sum);

const addThree = curriedAdd(1)(2)
console.log(addThree('a')) // Output: '3a', no type error

curry 함수가 인자를 모두 any로 처리하고 있기 때문이다. 

 

2.2. Solve: 도와줘요 제네릭!

들어오는 함수 타입을 제네릭으로 처리해보자. fn(커링할 함수)의 파라미터 타입을 제네릭으로 지정하면, collectedArgs은 fn의 파라미터 타입에 맞게 제한된다.

export function curry<Fn extends AnyFunc>(
  fn: Fn,
  ...collectedArgs: Partial<Parameters<Fn>>
):{
  return (...newArgs) => {
    const allArgs = [...collectedArgs, ...newArgs];

    return isComplete(allArgs, fn.length)
      ? fn(...allArgs)
      : curry(fn, ...allArgs);
  };
}

 

성공!

 


3. 문제 발견: A rest parameter must be of an array type

3.1. Problem

지금 collectedArgs의 타입은 Partial<Parameters<Fn>>이다. 근데 여기에 에러가 뜬다. 

 

A rest parameter must be of an array type..? 그럼 지금은 array type이 아니란 말인가? 

 

Partial<T>가 뭘 반환하는지 보면,

type Example = (a: number, b: string, c: boolean) => void;
type Params = Parameters<Example>; // [number, string, boolean]
type PartialParams = Partial<Params>; 
// 결과: { 0?: number; 1?: string; 2?: boolean }

인덱스 시그니처 객체를 반환한다!

{
  0?: number;
  1?: string;
  2?: boolean;
}

 

그런데 중요한건 ...(rest operator)는 배열이나 튜플만 펼칠 수 있다는 점이다. 즉, Partial<Parameters<Fn>>는 객체 타입이라서 ...로 확장 불가능하다. 

 

3.2. Solve: PartialTuple 

문제 해결: 배열 형태로 변환하기

그럼, rest parameter로 사용할 수 있는 형태로 바꿔보자. 

type Example = [number, string, boolean];
type Result = PartialTuple<Example>;
// ✅ 결과: [number?, string?, boolean?]

이렇게 동작하는 PartialTuple 타입을 따로 만들었다.

 

튜플 타입을 사용하는 또 다른 이유

커링의 본질은 인자를 "순서대로" 부분적으로 적용하는 것이다. 즉, 커링에서는 앞의 인자부터 차례대로 순서를 지키며 채워야 한다. 중간 인자만 선택적으로 제공하는 것은 허용되지 않아야 한다.

type Example = (a: number, b: string, c: boolean) => void;

// 허용 (✅)
curry(example)(1);                 // [number]
curry(example)(1, "hello");        // [number, string]
curry(example)(1, "hello", true);  // [number, string, boolean]

// ❌ 허용하면 안 되는 경우
curry(example)(true);              // [boolean] ❌
curry(example)(, "hello");         // [string] ❌

 

[number?, string?, boolean?]에서 ?는 해당 요소를 선택적으로 생략할 수 있음을 의미하지만, 타입스크립트의 튜플은 앞의 요소가 비어 있으면 뒤의 요소만 단독으로 채울 수 없다는 규칙이 있다. 그래서 잘못된 인자 전달을 타입 수준에서 자동으로 막을 수 있다. 

 

🚀 PartialTuple 구현하기

type PartialTuple<
  Params extends any[],
  Accumulated extends any[] = []
> = Params extends [infer CurrentParam, ...infer RemainingParams]
  ? PartialTuple<RemainingParams, [...Accumulated, CurrentParam?]>
  : [...Accumulated, ...Params];

 

그리고 이걸 적용하면, 

export function curry<Fn extends AnyFunc>(
  fn: Fn,
  ...collectedArgs: PartialTuple<Parameters<Fn>>
){
  return (...newArgs) => {
    const allArgs = [...collectedArgs, ...newArgs];

    return isComplete(allArgs, fn.length)
      ? fn(...(allArgs as Parameters<Fn>))
      : curry(fn, ...(allArgs as PartialTuple<Parameters<Fn>>));
  };
}

 

✅ A rest parameter must be of an array type 오류가 해결되었다!

✅ 그리고 잘못된 인자 전달도 제대로 오류를 잡아낸다. 

잘 그어진 빨간줄

3.3. PartialTuple Breakdown

type PartialTuple<
  Params extends any[],
  Accumulated extends any[] = []
> = Params extends [infer CurrentParam, ...infer RemainingParams]
  ? PartialTuple<RemainingParams, [...Accumulated, CurrentParam?]>
  : [...Accumulated, ...Params];
  
// Usage
PartialTuple<[number, string, boolean]>
Params → 현재 처리할 파라미터 목록
Accumulated → 누적된 파라미터들
CurrentParam → 현재 단계에서 처리 중인 파라미터
RemainingParams → 이후 처리할 남은 파라미터들

 

🧠 동작 흐름 보기

PartialTuple은 튜플의 각 요소를 재귀적으로 optional로 변환한다.

PartialTuple<[number, string, boolean]>
→ PartialTuple<[string, boolean], [number?]>
→ PartialTuple<[boolean], [number?, string?]>
→ PartialTuple<[], [number?, string?, boolean?]>
→ 결과: [number?, string?, boolean?]

👇 더 자세한 설명

더보기

① 초기 호출

PartialTuple<[number, string, boolean]>
  • Params = [number, string, boolean]
  • Accumulated = [] (초기 상태)

분해 (Destructuring):

  • CurrentParam = number
  • RemainingParams = [string, boolean]

재귀 호출:

PartialTuple<[string, boolean], [number?]>

number는 optional로 처리되어 Accumulated에 추가됨.

 

② 두번째 호출

PartialTuple<[string, boolean], [number?]>

분해:

  • CurrentParam = string
  • RemainingParams = [boolean]

재귀 호출:

PartialTuple<[boolean], [number?, string?]>

 

③ 세번째 호출

PartialTuple<[boolean], [number?, string?]>
  • Params = [boolean]
  • Accumulated = [number?, string?]

분해:

  • CurrentParam = boolean
  • RemainingParams = [] (더 이상 남은 요소 없음)

재귀 호출:

PartialTuple<[], [number?, string?, boolean?]>

 

④ 종료 조건 (Base Case)

PartialTuple<[], [number?, string?, boolean?]>
  • Params = [] → 재귀 종료 조건 도달
  • Accumulated = [number?, string?, boolean?]

최종 반환:

[...Accumulated, ...Params] 
// 결과: [number?, string?, boolean?]

 

🧠 문법 공부

 

① extend

  • extends는 조건부 타입(Conditional Types)에서 사용된다.
  • 타입이 특정 조건을 만족하는지 검사하는 역할을 한다.
type Check<T> = T extends string ? "Yes" : "No";
type Result1 = Check<"hello">; // "Yes"
type Result2 = Check<123>;     // "No"
// PartialTuple에서 
Params extends [infer CurrentParam, ...infer RemainingParams]
  • Params가 튜플 구조인지 확인하고, 맞다면 infer를 통해 타입을 추론한다.

 

 infer를 활용한 패턴 매칭

  • infer는 타입스크립트의 타입 추론(Inferencing) 기능이다.
  • 조건부 타입 안에서만 사용 가능하다.
  • infer를 사용하면 타입을 임시로 변수에 저장할 수 있다.
// 기본 사용법
type UnpackArray<T> = T extends (infer U)[] ? U : T;

type Result1 = UnpackArray<number[]>; // number
type Result2 = UnpackArray<string>;   // string
// PartialTuple에서
Params extends [infer CurrentParam, ...infer RemainingParams]
  • Params가 튜플 구조인지 검사한다.
  • infer CurrentParam: Params의 첫 번째 요소를 CurrentParam으로 추론한다.
  • ...infer RemainingParams: 나머지 요소들을 RemainingParams로 추출한다. 

 

③ [Head, ...Tail] 구조

이 구조는 새로운 튜플을 만들어낸다. Head는 required, Tail은 optional로, 원소가 최소 한개는 있는 튜플이다. 

즉, 다음 코드는 사실상 Params가 원소가 하나 이상 있는지 확인하는 역할이다. 

Params extends [infer CurrentParam, ...infer RemainingParams]

 


4. 최소 한 개의 인자 보장하기: AtLeastOneParam

4.1. Problem

커링 함수는 원래 함수를 부분적으로 적용하는 기법이기 때문에, 최소한 하나의 인자는 제공해야 의미가 있다. curriedAdd()같은 호출은 논리적으로 잘못된 코드다. 

const add = (a: number, b: number, c: number) => a + b + c;
const curriedAdd = curry(add);

curriedAdd(); // ❌ No type error, 근데 에러가 나야함

 

런타임 에러를 방지하기 위해서 타입 수준해서 예방해보자. 

4.2. Solve

이 문제를 해결하기 위해 AtLeastOneParam 타입을 도입한다.

type AtLeastOneParam<Fn extends AnyFunc> = Parameters<Fn> extends [
  infer Head,
  ...infer Tail
]
  ? [Head, ...Partial<Tail>]
  : never;

 

Fn의 파라미터 개수가 0개라면 never를 반환해서 호출 자체를 금지한다. 이 구조로 최소 하나의 인자를 받도록 강제돼서 curriedAdd()와 같은 호출이 방지된다. 

 

Fn의 파라미터 개수가 1개 이상이면 구조를 살짝 변경한다. 첫 번째 인자는 필수로 유지하고, 나머지 인자들은 optional로 변경한다. AtLeastOneParam 타입은 curry 함수의 newArgs에 적용될건데, 이렇게 변경해줘야 최소 한 개의 인자 보장과 유동적인 부분 적용이 가능하다. 

type Fn = (a: number, b: string, c: boolean) => void;
type Result = AtLeastOneParam<Fn>;
// 결과: [number, string?, boolean?]

 

성공!


5. CurriedFunction

이제 curry 함수가 반환하는 CurriedFunction 타입을 정의할 차례다.

export function curry<Fn extends AnyFunc>(
  fn: Fn,
  ...collectedArgs: PartialTuple<Parameters<Fn>>
): CurriedFunction<Fn> {
  return (...newArgs) => {
    const allArgs = [...collectedArgs, ...newArgs];

    return isComplete(allArgs, fn.length)
      ? fn(...(allArgs as Parameters<Fn>))
      : curry(fn, ...(allArgs as PartialTuple<Parameters<Fn>>));
  };
}

 

curry 함수가 반환하는 CurriedFunction 타입을 만들어보자.

 

🚀 CurriedFunction 구현하기

type CurriedFunction<Fn extends AnyFunc> = <
  AppliedParams extends AtLeastOneParam<Fn>
>(
  ...args: AppliedParams
) => RemainingParameters<AppliedParams, Parameters<Fn>> extends [any, ...any[]] 
  ? // ✅ 남은 인자가 1개 이상이면 다음 커링 호출 반환
    CurriedFunction<
      (
        ...args: RemainingParameters<AppliedParams, Parameters<Fn>>
      ) => ReturnType<Fn>
    >
  : // ✅ 모든 인자가 채워졌으면 최종 결과 반환
    ReturnType<Fn>;

 

  • CurriedFunction은 인자를 받아서:
    1. 남은 인자가 있다면: 재귀적으로 다시 CurriedFunction 반환
    2. 모든 인자가 채워졌다면: ReturnType<Fn> 반환

 

① args 타입 

AppliedParams extends AtLeastOneParam<Fn>
  • args에는 최소 한 개의 인자가 전달되어야 한다.
  • 이를 위해 AtLeastOneParam<Fn> 타입을 사용하여 빈 인자 호출을 방지한다.

 

② RemainingParameters

모든 인자가 채워졌는지 확인하기 위해 RemainingParameters 타입을 사용한다.

type RemainingParameters<
  AppliedArgs extends any[],
  ExpectedArgs extends any[]
> = AppliedArgs extends [any, ...infer RestApplied]
  ? ExpectedArgs extends [any, ...infer RestExpected]
    ? // 현재 인자를 소모하고, 나머지 인자들로 재귀 호출
      RemainingParameters<RestApplied, RestExpected>
    : [] // 예상 인자가 더 이상 없으면 빈 배열 반환
  : ExpectedArgs; // 모든 적용된 인자를 소모한 후 남은 예상 인자 반환
  • AppliedArgs가 비어있지 않다면:
    • 첫 번째 인자를 제거하고 나머지(RestApplied)로 재귀 호출
    • ExpectedArgs에서도 대응되는 첫 번째 인자를 제거한 뒤 재귀 호출
  • 종료 조건:
    • AppliedArgs가 비어있으면, 남아있는 ExpectedArgs가 바로 남은 인자다.
type Result = RemainingParameters<[1, 2], [number, string, boolean]>;
// 결과: [boolean]

전체 코드

type AnyFunc = (...args: any[]) => any;

type AtLeastOneParam<Fn extends AnyFunc> = Parameters<Fn> extends [
  infer Head,
  ...infer Tail
]
  ? [Head, ...Partial<Tail>]
  : never;

type PartialTuple<
  Params extends any[],
  Accumulated extends any[] = []
> = Params extends [infer CurrentParam, ...infer RemainingParams]
  ? PartialTuple<RemainingParams, [...Accumulated, CurrentParam?]>
  : [...Accumulated, ...Params];

type RemainingParameters<
  AppliedArgs extends any[],
  ExpectedArgs extends any[]
> = AppliedArgs extends [any, ...infer RestApplied]
  ? ExpectedArgs extends [any, ...infer RestExpected]
    ? // 현재 인자를 소모하고, 나머지 인자들로 재귀 호출
      RemainingParameters<RestApplied, RestExpected>
    : [] // 예상 인자가 더 이상 없으면 빈 배열 반환
  : ExpectedArgs; // 모든 적용된 인자를 소모한 후 남은 예상 인자 반환

type CurriedFunction<Fn extends AnyFunc> = <
  AppliedParams extends AtLeastOneParam<Fn>
>(
  ...newArgs: AppliedParams
) => RemainingParameters<AppliedParams, Parameters<Fn>> extends [any, ...any[]] // 남은 인자가 1개 이상이면
  ? // true: 다음 커링 호출
    CurriedFunction<
      (
        ...args: RemainingParameters<AppliedParams, Parameters<Fn>>
      ) => ReturnType<Fn>
    >
  : ReturnType<Fn>; // false: 모든 인자가 채워졌으므로 결과 반환

const isComplete = (args: any[], expectedLength: number): boolean =>
  args.length >= expectedLength;

export function curry<Fn extends AnyFunc>(
  fn: Fn,
  ...collectedArgs: PartialTuple<Parameters<Fn>>
): CurriedFunction<Fn> {
  return (...newArgs) => {
    const allArgs = [...collectedArgs, ...newArgs];

    return isComplete(allArgs, fn.length)
      ? fn(...(allArgs as Parameters<Fn>))
      : curry(fn, ...(allArgs as PartialTuple<Parameters<Fn>>));
  };
}

완성!