logo
Nostrss
Published on

이제는 타입스크립트로 구현해보자 😭

Authors
TypeScript로 무장하기

지금까지의 여정

지금까지 함수형 프로그래밍을 위한 도구와 같은 기능들을 하나씩 만들어왔다.

그런데 생각해보니 놓친게 있다. 자바스크립트로만 작성하다 보니 타입에 대한 고민을 하지 않았다. 실무에서는 타입스크립트를 사용하는데..

그래서 지금까지 만든 함수들을 TypeScript로 변환하는 작업을 하려고 한다.

map

JS 버전

const map = (f, iter) => {
  let res = []
  for (const a of iter) {
    res.push(f(a))
  }
  return res
}

f는 어떤 타입의 값을 받아서 다른 타입의 값을 반환하는 함수다. iterf가 받는 타입의 값을 내보내는 Iterable이다. 결과는 f가 반환하는 타입의 배열이다.

TS 버전

const map = <T, U>(f: (a: T) => U, iter: Iterable<T>): U[] => {
  let res: U[] = []
  for (const a of iter) {
    res.push(f(a))
  }
  return res
}
// T = number, U = string으로 자동 추론
map((n) => n.toFixed(2), [1, 2, 3])
// 결과 타입: string[]

// T = { name: string, price: number }, U = string으로 추론
map((p) => p.name, products)
// 결과 타입: string[]

Iterable<T>을 사용했기 때문에 배열뿐만 아니라 Set, Map, Generator 등 모든 Iterable에서 동작한다는 점은 JS 버전과 동일하다.

filter

JS 버전

const filter = (f, iter) => {
  let res = []
  for (const a of iter) {
    if (f(a)) res.push(a)
  }
  return res
}

TS 버전

const filter = <T>(f: (a: T) => boolean, iter: Iterable<T>): T[] => {
  let res: T[] = []
  for (const a of iter) {
    if (f(a)) res.push(a)
  }
  return res
}

map보다 더 간단하다. filter요소를 걸러내기만 하므로 입력과 출력의 타입이 같다. 제네릭이 <T> 하나면 충분하다.

  • fT를 받아 boolean을 반환하는데 이를 술어 함수(predicate) 라고 부른다.
  • iterIterable<T>
  • 결과는 T[]
// T = number로 추론
filter((n) => n % 2 === 0, [1, 2, 3, 4])
// 결과 타입: number[]

// T = { name: string, price: number }로 추론
filter((p) => p.price >= 20000, products)
// 결과 타입: { name: string, price: number }[]

reduce(어렵다...)

JS 버전의 구조 다시 보기

const reduce = (f, acc, iter) => {
  if (!iter) {
    iter = acc[Symbol.iterator]()
    acc = iter.next().value
  }
  for (const a of iter) {
    acc = f(acc, a)
  }
  return acc
}

이전에 만든 reduce는 두 가지 방식으로 호출할 수 있다.

// 방식 1: 초기값 있음
reduce(add, 0, [1, 2, 3, 4, 5]) // 15

// 방식 2: 초기값 없음 — 첫 번째 요소가 초기값
reduce(add, [1, 2, 3, 4, 5]) // 15

방식 1에서 acc는 **초기값(숫자)**이고, 방식 2에서 accIterable 자체다. 같은 파라미터 이름인데 타입이 완전히 다르다. 이걸 어떻게 타입으로 표현할까?

단순 제네릭으로는 불가능한 이유

첫 번째 시도를 해보자.

// 시도 1: 하나의 시그니처로 표현?
const reduce = <T, R>(f: (acc: R, a: T) => R, acc: R | Iterable<T>, iter?: Iterable<T>): R => {
  // ...
}

이렇게 하면 acc의 타입이 R | Iterable<T>이 된다. 문제는 반환 타입도 불확실해진다는 것이다.

// 초기값 있는 호출
reduce(add, 0, [1, 2, 3]) // 반환 타입이 R = number? 🤔

// 초기값 없는 호출
reduce(add, [1, 2, 3]) // 반환 타입이 R = number[]? number? 🤔

TypeScript가 호출 패턴에 따라 반환 타입을 정확히 추론하지 못한다. 사용하는 쪽에서 매번 타입 가드나 타입 단언을 해야 한다. 이건 좋지 않다.

함수 오버로딩이란?

함수 오버로딩같은 이름의 함수에 여러 호출 시그니처를 정의하는 것이다. TypeScript는 호출 시 인자 패턴을 보고 가장 적합한 시그니처를 위에서 아래로 매칭한다.

참고

구조는 이렇다.

// 오버로드 시그니처 (선언만)
function myFunc(a: number): string
function myFunc(a: string): number

// 구현 시그니처 (실제 로직)
function myFunc(a: number | string): string | number {
  if (typeof a === 'number') return a.toString()
  return parseInt(a)
}
  • 오버로드 시그니처: 외부에서 보이는 함수의 "얼굴". 호출자는 이 시그니처를 기준으로 타입 검사를 받는다.
  • 구현 시그니처: 실제 로직이 들어있는 부분. 모든 오버로드를 포괄할 수 있을 만큼 넓은 타입을 사용한다.

호출 시 TypeScript는 오버로드 시그니처를 위에서 아래로 확인하며, 첫 번째로 매칭되는 시그니처의 반환 타입을 사용한다.

reduce에 오버로딩 적용하기

// 오버로드 1: 초기값 있는 경우
function reduce<T, R>(f: (acc: R, a: T) => R, acc: R, iter: Iterable<T>): R
// 오버로드 2: 초기값 없는 경우
function reduce<T>(f: (acc: T, a: T) => T, iter: Iterable<T>): T

// 구현 시그니처
function reduce<T, R>(
  f: (acc: R | T, a: T) => R | T,
  acc: R | Iterable<T>,
  iter?: Iterable<T>
): R | T {
  if (!iter) {
    const iterator = (acc as Iterable<T>)[Symbol.iterator]()
    acc = iterator.next().value as T
    iter = { [Symbol.iterator]: () => iterator }
  }
  let result = acc as R | T
  for (const a of iter) {
    result = f(result, a)
  }
  return result
}

두 가지 오버로드를 정의했다.

오버로드 1reduce(f, acc, iter): 인자 3개. acc는 초기값 R, iterIterable<T>. 반환 타입은 R.

오버로드 2reduce(f, iter): 인자 2개. iter에서 첫 요소를 초기값으로 사용. 반환 타입은 T.

이제 TypeScript가 호출 패턴에 따라 반환 타입을 정확히 추론한다.

const add = (a: number, b: number) => a + b

// 오버로드 1 매칭 → 반환 타입: number
reduce(add, 0, [1, 2, 3, 4, 5]) // 15

// 오버로드 2 매칭 → 반환 타입: number
reduce(add, [1, 2, 3, 4, 5]) // 15

유니온 타입 vs 오버로딩 — 왜 오버로딩이 나은가?

유니온 타입으로 acc: R | Iterable<T>를 사용하면 어떻게 될까?

// 유니온 방식 — 반환 타입이 R | T
const result = reduce(add, 0, [1, 2, 3])
// result의 타입: number | number  →  number (이 경우는 괜찮지만...)

// 다른 예시: acc 타입과 요소 타입이 다른 경우
const result2 = reduce((acc, p) => acc + p.price, 0, products)
// 유니온이면 반환 타입이 number | Product가 되어
// result2.toFixed(2) 같은 코드에서 에러 발생!

오버로딩이면 이 문제가 없다.

// 오버로드 1 매칭: f의 acc가 R=number, a가 T=Product
// 반환 타입: R = number (정확!)
const result = reduce((acc, p) => acc + p.price, 0, products)
result.toFixed(2) // OK!

호출자 입장에서 반환 타입이 명확하게 좁혀진다.

구현 시그니처 내부의 타입 단언

구현 시그니처를 다시 보자.

function reduce<T, R>(
  f: (acc: R | T, a: T) => R | T,
  acc: R | Iterable<T>, // acc가 R일 수도, Iterable<T>일 수도
  iter?: Iterable<T>
): R | T {
  if (!iter) {
    const iterator = (acc as Iterable<T>)[Symbol.iterator]()
    acc = iterator.next().value as T
    iter = { [Symbol.iterator]: () => iterator }
  }
  let result = acc as R | T
  for (const a of iter) {
    result = f(result, a)
  }
  return result
}

(acc as Iterable<T>)iter가 없으면 acc가 Iterable이라고 확신할 수 있다. 오버로드 시그니처가 이를 보장하기 때문이다. 하지만 구현 시그니처의 타입은 R | Iterable<T>로 넓기 때문에, TypeScript에게 "이 경우엔 Iterable이야"라고 알려주는 것이 **타입 단언(as)**이다.

오버로드 시그니처가 외부의 타입 안전성을 보장하고, 구현 내부에서는 as로 처리했다. 단언없이 구현하는 방법은 없을까?

go(매우 어렵다...)

JS 버전 복습

이전에 만든 go 함수를 다시 보자.

const go = (...args) => reduce((a, f) => f(a), args)

go는 첫 번째 인자를 초기값으로, 나머지 인자들(함수)을 순서대로 적용한다.

go(
  0,
  (a) => a + 1,
  (a) => a + 10,
  (a) => a + 100,
  console.log // 111
)

첫 시도: 단순한 시그니처

const go = (initial: unknown, ...fns: Array<(arg: any) => any>): unknown => {
  return reduce((a, f) => f(a), initial, fns)
}

동작은 하지만 타입 추론이 전혀 안 된다. 반환 타입이 unknown이고, 중간 단계에서 어떤 타입이 흐르는지도 알 수 없다.

const result = go(
  0,
  (a) => a + 1, // a의 타입이 any
  (a) => a + 10 // a의 타입이 any
)
// result의 타입: unknown 😢

오버로딩으로 해결

함수 1개부터 5개까지, 각 경우에 대해 오버로드를 정의하면 타입 체인을 만들 수 있다.

// 함수 1개
function go<A, B>(initial: A, f1: (a: A) => B): B
// 함수 2개
function go<A, B, C>(initial: A, f1: (a: A) => B, f2: (a: B) => C): C
// 함수 3개
function go<A, B, C, D>(initial: A, f1: (a: A) => B, f2: (a: B) => C, f3: (a: C) => D): D
// 함수 4개
function go<A, B, C, D, E>(
  initial: A,
  f1: (a: A) => B,
  f2: (a: B) => C,
  f3: (a: C) => D,
  f4: (a: D) => E
): E
// 함수 5개
function go<A, B, C, D, E, F>(
  initial: A,
  f1: (a: A) => B,
  f2: (a: B) => C,
  f3: (a: C) => D,
  f4: (a: D) => E,
  f5: (a: E) => F
): F

// 구현 시그니처
function go(initial: unknown, ...fns: Array<(arg: any) => any>): unknown {
  return fns.reduce((acc, f) => f(acc), initial)
}

핵심은 제네릭 타입 파라미터가 체인처럼 연결된다는 것이다.

함수 3개 오버로드를 자세히 보자.

function go<A, B, C, D>(
  initial: A, // 시작: A
  f1: (a: A) => B, // A → B
  f2: (a: B) => C, // B → C
  f3: (a: C) => D // C → D
): D // 최종 결과: D

A → B → C → D로 타입이 흐른다. TypeScript가 각 단계의 타입을 자동으로 추론한다.

const result = go(
  0, // A = number
  (a) => a + 1, // (number) => number, B = number
  (a) => a + 10, // (number) => number, C = number
  (a) => `결과: ${a}` // (number) => string, D = string
)
// result의 타입: string ✅

IDE에서 a에 마우스를 올리면 각 단계의 타입이 정확하게 표시된다.

오버로드 개수를 넘기면?

오버로드를 5개까지 정의했는데, 함수를 6개 이상 전달하면 어떻게 될까?

go(
  0,
  (a) => a + 1,
  (a) => a + 10,
  (a) => a + 100,
  console.log,
  console.log,
  console.log // ❌ 에러!
)

TypeScript는 오버로드 시그니처를 위에서 아래로 매칭하는데, 인자 7개(초기값 + 함수 6개)에 맞는 시그니처가 없다. 이런 에러가 발생한다.

No overload expects 7 arguments, but overloads do exist that expect 2, 3, 4, 5, or 6 arguments.

해결법은 단순하다. 오버로드를 더 추가하면 된다. 이거 맞아?😭

// 함수 6개
function go<A, B, C, D, E, F, G>(
  initial: A,
  f1: (a: A) => B,
  f2: (a: B) => C,
  f3: (a: C) => D,
  f4: (a: D) => E,
  f5: (a: E) => F,
  f6: (a: F) => G
): G

실무에서는 보통 5~10개 정도 정의해두면 충분하다. 이보다 길어지면 함수를 분리하는 게 좋은 신호다.

go의 구현 시그니처

function go(initial: unknown, ...fns: Array<(arg: any) => any>): unknown {
  return fns.reduce((acc, f) => f(acc), initial)
}

구현 시그니처는 reduce의 경우와 같은 원리다. 오버로드 시그니처가 외부의 타입 안전성을 보장하므로, 내부에서는 unknownany를 사용해 유연하게 처리한다.

실제 동작을 확인해보자.

const add = (a: number, b: number) => a + b

const result = go(
  add(0, 1), // 1
  (a) => a + 10, // 11
  (a) => a + 100 // 111
)

console.log(result) // 111, 타입: number

go의 첫 번째 인자 add(0, 1)의 결과가 number이므로 A = number가 되고, 이후 함수들도 number → number로 추론되어 최종 반환 타입이 number가 된다.

함수 오버로딩 vs 유니온 타입 비교 정리

기준유니온 타입함수 오버로딩
타입 추론 정확도반환 타입이 유니온으로 넓어짐호출 패턴에 따라 정확한 반환 타입
호출자 편의성타입 가드/단언 필요할 수 있음자연스러운 사용, 추가 처리 불필요
IDE 자동완성유니온의 모든 멤버 표시매칭된 시그니처 기준으로 정확한 제안
유지보수성시그니처 1개로 간결오버로드 수만큼 시그니처 증가
적합한 경우인자 패턴과 관계없이 반환 타입이 동일인자 패턴에 따라 반환 타입이 달라지는 경우

오버로딩이 빛나는 순간 — 인자의 개수나 타입 조합에 따라 반환 타입이 달라져야 하는 경우다. reduce의 초기값 유무에 따른 반환 타입, go의 함수 개수에 따른 최종 타입이 정확히 이 경우에 해당한다.

반면, 반환 타입이 항상 같다면 유니온이나 옵셔널 파라미터로 충분하다. 오버로딩은 강력하지만 시그니처가 늘어나는 비용이 있으니, 정말 필요한 곳에 사용하자.

인자 패턴에 따라 반환 타입이 달라지는 경우 → 오버로딩, 그렇지 않으면 → 유니온/옵셔널

참고 자료

출처

인프런 함수형 프로그래밍과 JavaScript ES6+ 강의를 학습하고 정리한 내용입니다.

함수형 프로그래밍과 JavaScript ES6+