PHpullh

TYPESCRIPT · 심층 가이드

TypeScript 함수형 완전 정리

합성과 커링은 값 수준에서는 몇 줄이면 되지만, TypeScript에서는 타입이 파이프라인을 따라 흘러가게 만드는 일이 구현의 절반 이상을 차지합니다.

주제 15개 · 예제 코드 포함 · 최종 수정 2026-09-03 · 작성 pullh 편집팀

TypeScript의 함수형 코드는 구조적 타이핑 위에 서 있습니다. 인터페이스 이름이 아니라 모양이 맞으면 통과하므로 작은 함수를 조립하기 좋지만, 대신 추론이 끊기는 지점이 눈에 잘 띄지 않습니다. compose나 pipe를 직접 만들어 보면 이 사실이 분명해집니다. 인자 개수마다 오버로드를 얹거나 가변 튜플 제네릭을 쓰지 않으면 중간 단계의 타입이 unknown으로 뭉개져, 파이프라인 끝에서만 에러가 터집니다.

순수 함수와 불변 업데이트가 나머지 전부의 전제입니다. 여기서 만든 규칙을 값 흐름으로 바꾼 것이 compose와 pipeline이고, 인자를 하나씩 고정하는 curried 함수는 그 파이프에 넣을 단항 함수를 만드는 도구입니다. 실패를 값으로 다루는 Result 스타일 매핑과 Maybe 스타일 처리는 마지막에 보는 편이 낫습니다. 앞의 합성 감각이 있어야 왜 예외 대신 반환값을 택하는지가 납득됩니다.

불변 업데이트에서 가장 흔한 착각은 스프레드가 깊은 복사라는 오해입니다. { ...state, count: 1 }은 한 겹만 새로 만들고 중첩 객체는 원본과 같은 참조를 공유하므로, 안쪽을 고치면 이전 상태까지 함께 변합니다. readonly와 as const도 컴파일 타임 표시일 뿐 런타임에는 사라지니, 외부에 넘긴 배열이 변형되지 않는다는 보장은 되지 못합니다. 중첩 구조는 층마다 새 객체를 만들어 올려야 합니다.

01순수 함수

addTax는 인자로 받은 가격에 1.1을 곱해 돌려주는 화살표 함수이며, 외부 상태를 읽거나 바꾸지 않습니다. 이런 순수 함수는 입력만 주면 결과가 정해지므로 테스트에서 준비 코드가 필요 없습니다. 다만 10 * 1.1은 부동소수점 특성상 정확히 11이 아닐 수 있어 금액 비교에는 반올림이 따라와야 합니다.

TypeScript code

// 순수 함수
const addTax = (price: number): number => price * 1.1;
console.log(addTax(10));
알아두면 좋은 점

순수 함수는 같은 입력에 항상 같은 결과를 내므로 캐시를 붙이기 쉽고, 테스트에서 준비 코드 없이 값만 비교하면 됩니다.

자주 하는 실수

10 * 1.1은 이진 부동소수점에서 정확히 11이 아니므로, 금액을 ===로 견주면 맞을 것 같은 검사가 실패합니다.

02불변 업데이트

기존 state를 건드리지 않고 스프레드로 새 객체를 만들면서 page는 1 증가시키고 tags에는 원소 하나를 덧붙입니다. 화면 상태를 갱신할 때 이전 값과 새 값을 함께 비교해야 하는 상황에서 쓰는 방식입니다. 객체 스프레드는 한 겹만 복사하므로 tags를 따로 펼치지 않았다면 두 상태가 같은 배열을 공유하게 됩니다.

TypeScript code

// 불변 업데이트
const state = { page: 1, tags: ["x"] };
const next = { ...state, page: state.page + 1, tags: [...state.tags, "y"] };
console.log(next);
알아두면 좋은 점

스프레드는 뒤에 쓴 속성이 앞을 덮으므로, 기존 값을 먼저 펼치고 바꿀 필드를 뒤에 두는 순서를 지켜야 갱신이 반영됩니다.

자주 하는 실수

tags를 새로 펼치지 않고 next.tags.push('y')를 부르면 원본까지 바뀌어, 이전 상태와 비교하는 코드가 항상 같다고 판단합니다.

03Maybe 스타일 처리

함수 두 개를 담은 배열을 reduce로 훑으면서 초깃값 3에 차례로 적용해 최종 값을 얻습니다. 처리 단계가 늘었다 줄었다 하는 변환 흐름을 배열 하나로 관리하고 싶을 때 유용합니다. steps의 원소 타입이 통일돼 있어야 누적값 타입 추론이 유지되고, 중간에 문자열 반환 함수가 섞이면 컴파일 단계에서 걸립니다.

TypeScript code

// Maybe 스타일 처리
const steps = [(value: number) => value + 1, (value: number) => value * 2];
console.log(steps.reduce((value, step) => step(value), 3));
알아두면 좋은 점

단계 배열의 타입을 ((v: number) => number)[]로 적어 두면 모양이 다른 함수를 넣었을 때 배열 정의 시점에 걸립니다.

자주 하는 실수

단계마다 타입이 달라지는 변환을 한 배열에 담으면 공통 타입이 넓어져, 파이프 이후의 추론이 전부 무력해집니다.

04Result 스타일 매핑

제너레이터 numbers가 1, 2, 4를 순서대로 내놓고, 스프레드가 이를 끝까지 소비해 배열로 만듭니다. 값을 미리 다 만들지 않고 필요할 때 하나씩 흘려보내는 구조를 만들 때 이 형태를 씁니다. 스프레드나 for...of로 한 번 소비한 제너레이터 객체는 소진되어, 다시 돌리려면 함수를 새로 호출해야 합니다.

TypeScript code

// Result 스타일 매핑
function* numbers() { yield 1; yield 2; yield 4; }
console.log([...numbers()]);
알아두면 좋은 점

제너레이터는 yield마다 실행을 멈추므로, 무한 수열을 정의해 두고 필요한 개수만 꺼내 쓰는 구조를 만들 수 있습니다.

자주 하는 실수

한 번 펼친 제너레이터 객체를 변수에 담아 두고 다시 순회하면 아무 값도 나오지 않습니다. 재사용하려면 함수를 다시 호출해야 합니다.

05fold 합계

합계를 접어 나가는 대신, 값 하나에 세율을 곱한 결과만 계산해 출력하는 코드입니다. 세금이나 할인처럼 규칙이 한 줄로 끝나는 계산은 함수로 떼어 두면 여러 곳에서 재사용됩니다. 반환 타입을 number로 명시해 두면 나중에 문자열을 반환하도록 잘못 고쳤을 때 호출부가 아니라 함수 정의에서 오류가 납니다.

TypeScript code

// fold 합계
const addTax = (price: number): number => price * 1.1;
console.log(addTax(50));
알아두면 좋은 점

세율처럼 바뀌는 값은 인자로 받아 함수를 돌려주는 형태로 두면, 국가별 세율을 미리 고정한 계산 함수를 여러 개 만들어 둘 수 있습니다.

자주 하는 실수

곱셈 결과를 그대로 저장하면 소수점 아래가 계속 늘어나므로, 통화 단위에 맞춘 반올림 시점을 한 곳으로 정해 두어야 합니다.

06compose

합성 대신 객체 복사로 상태 전이를 표현합니다. 이전 상태에서 필드 몇 개만 바꾼 새 값을 만드는 이 패턴은 리듀서 안에서 그대로 재현됩니다. TypeScript에서 state에 readonly를 붙여도 검사는 컴파일 시점에만 이뤄지므로, 실행 시점의 변경까지 막으려면 Object.freeze가 필요합니다.

TypeScript code

// compose
const state = { page: 1, tags: ["x"] };
const next = { ...state, page: state.page + 1, tags: [...state.tags, "y"] };
console.log(next);
알아두면 좋은 점

상태를 새 객체로 돌려주면 참조가 바뀌므로, 화면 갱신 여부를 필드 전체 비교 없이 참조 비교만으로 판단할 수 있습니다.

자주 하는 실수

readonly는 컴파일 시점 표시일 뿐이라, any로 단언하거나 외부 라이브러리를 거치면 실행 중에 값이 바뀌어도 아무 경고가 없습니다.

07partial 적용

인자를 미리 묶어 두는 대신, 준비된 함수 목록을 초깃값 7에 순서대로 먹입니다. 적용 순서가 결과를 바꾸므로 배열 순서 자체가 곧 사양이 됩니다. reduce에 초깃값을 넘기지 않으면 빈 배열에서 예외가 나므로, 시작 값을 명시한 지금 형태가 안전합니다.

TypeScript code

// partial 적용
const steps = [(value: number) => value + 1, (value: number) => value * 2];
console.log(steps.reduce((value, step) => step(value), 7));
알아두면 좋은 점

적용 순서가 결과를 바꾸므로 이 배열은 사실상 사양 문서이며, 순서를 바꾸는 변경은 테스트로 고정해 두는 편이 안전합니다.

자주 하는 실수

초깃값을 빼면 첫 요소인 함수 자체가 누산기가 되어, 숫자가 아니라 함수에 다음 단계를 적용하려다 실행 중에 실패합니다.

08curried 함수

커링과 무관하게, yield 세 번으로 1, 2, 8을 차례로 내보내는 제너레이터를 정의하고 펼칩니다. 커서나 페이지 단위 조회처럼 다음 값을 요청받았을 때 계산하는 소스를 흉내 낼 때 쓰입니다. function*의 반환값은 반복자이자 반복 가능 객체라서 별도 어댑터 없이 스프레드에 바로 들어갑니다.

TypeScript code

// curried 함수
function* numbers() { yield 1; yield 2; yield 8; }
console.log([...numbers()]);
알아두면 좋은 점

제너레이터 객체는 Symbol.iterator를 자기 자신으로 구현하기 때문에, 별도 어댑터 없이 for...of와 스프레드에 그대로 들어갑니다.

자주 하는 실수

function*의 별표를 빠뜨리면 yield가 평범한 식별자로 해석되거나 문법 오류가 나, 의도와 전혀 다른 함수가 됩니다.

09pipeline

파이프라인이라는 이름과 달리 여기서는 단일 함수 addTax에 90을 넣어 한 단계 변환만 수행합니다. 단계를 늘릴 계획이더라도 처음에는 이렇게 함수 하나로 시작해 두는 편이 디버깅이 쉽습니다. 화살표 함수는 자체 this를 만들지 않으므로 나중에 객체 메서드로 옮기면 this가 바깥 스코프를 가리키게 됩니다.

TypeScript code

// pipeline
const addTax = (price: number): number => price * 1.1;
console.log(addTax(90));
알아두면 좋은 점

단계를 늘릴 때는 각 함수가 값 하나를 받아 값 하나를 돌려주도록 맞춰 두면, 중간에 새 단계를 끼워 넣어도 나머지가 그대로 동작합니다.

자주 하는 실수

화살표 함수는 자기 this를 만들지 않으므로, 이 함수를 객체 메서드 자리로 옮기면 this가 객체가 아닌 바깥 스코프를 가리킵니다.

10lazy iterator

지연 평가와 달리 이 코드는 스프레드로 즉시 새 객체와 새 배열을 만들어 냅니다. 값이 몇 개 안 되는 UI 상태라면 이 즉시 복사 비용이 문제 되지 않습니다. 반대로 원소가 아주 많은 배열을 갱신할 때마다 통째로 펼치면 복사 비용이 항목 수에 비례해 늘어난다는 점은 염두에 둬야 합니다.

TypeScript code

// lazy iterator
const state = { page: 1, tags: ["x"] };
const next = { ...state, page: state.page + 1, tags: [...state.tags, "y"] };
console.log(next);
알아두면 좋은 점

값이 아주 많다면 매번 전체를 펼치는 대신, 바뀐 부분만 새로 만들고 나머지는 참조를 공유하는 구조가 복사 비용을 크게 줄여 줍니다.

자주 하는 실수

목록 전체를 펼치는 갱신 코드를 렌더 함수 안에 두면, 항목 수에 비례한 복사가 화면이 갱신될 때마다 반복됩니다.

11패턴 분기

분기문 없이, 배열에 담긴 변환 함수들을 reduce로 이어 붙여 11을 흘려보냅니다. 조건마다 다른 함수를 배열에 담는 식으로 확장하면 switch 없이도 경우를 나눌 수 있습니다. 누적자 이름과 배열 이름이 다르다는 점만 주의하면 되는데, 콜백의 첫 인자는 이전 결과이고 둘째 인자가 현재 함수입니다.

TypeScript code

// 패턴 분기
const steps = [(value: number) => value + 1, (value: number) => value * 2];
console.log(steps.reduce((value, step) => step(value), 11));
알아두면 좋은 점

조건별 함수를 객체에 담아 키로 고르면, 새 갈래를 추가할 때 분기문을 건드리지 않고 항목만 늘리면 됩니다.

자주 하는 실수

reduce 콜백의 첫 인자를 현재 요소로 착각해 인자를 바꿔 쓰면, 누적값 대신 함수가 넘어가 조용히 잘못된 결과가 나옵니다.

12reducer 상태 누적

상태 누적 대신 고정된 세 값을 순서대로 내보내는 제너레이터를 만들고 배열로 수집합니다. 리듀서에 넣을 액션 시퀀스를 시험 삼아 만들 때 이런 소스가 쓸모 있습니다. 제너레이터 본문은 첫 next() 호출 전까지 한 줄도 실행되지 않아, 함수를 부르는 순간 부수 효과가 일어날 것이라 기대하면 어긋납니다.

TypeScript code

// reducer 상태 누적
function* numbers() { yield 1; yield 2; yield 12; }
console.log([...numbers()]);
알아두면 좋은 점

제너레이터 본문은 첫 요청 전까지 실행되지 않으므로, 준비 비용이 큰 작업을 안에 두어도 실제 소비 전까지는 아무 비용이 들지 않습니다.

자주 하는 실수

제너레이터를 호출만 하고 소비하지 않으면 안의 코드가 한 줄도 실행되지 않아, 부수 효과를 기대한 코드가 아무 일도 하지 않습니다.

13all predicate

술어 검사 대신 130에 세율을 적용한 값 하나를 계산합니다. 조건 판정 함수도 결국 같은 모양의 순수 함수이며 반환 타입만 boolean으로 바뀝니다. 한 줄 화살표 함수는 중괄호가 없을 때만 결과를 자동 반환하므로, 본문을 {}로 감싸는 순간 return을 빠뜨리기 쉽습니다.

TypeScript code

// all predicate
const addTax = (price: number): number => price * 1.1;
console.log(addTax(130));
알아두면 좋은 점

every는 빈 배열에 참을, some은 거짓을 돌려주므로 목록이 비었을 때의 의미를 먼저 정해 두어야 합니다.

자주 하는 실수

중괄호로 감싼 화살표 함수는 return이 없으면 undefined를 돌려주어, 술어로 쓰면 모든 항목이 거짓으로 판정됩니다.

14flatMap

평탄화가 아니라 스프레드 두 번으로 객체와 배열을 각각 새로 만드는 코드입니다. 태그 목록에 항목을 더하는 이런 갱신은 원본을 남겨 둬야 하는 이력 관리와 잘 맞습니다. [...state.tags, "y"]는 얕은 복사라 배열 안에 객체가 들어 있었다면 두 배열이 같은 객체를 가리킵니다.

TypeScript code

// flatMap
const state = { page: 1, tags: ["x"] };
const next = { ...state, page: state.page + 1, tags: [...state.tags, "y"] };
console.log(next);
알아두면 좋은 점

이력을 남기려면 갱신마다 만든 새 객체를 배열에 쌓아 두면 되고, 각 항목이 서로 다른 참조라 특정 시점으로 되돌리기 쉽습니다.

자주 하는 실수

배열 스프레드는 껍데기만 새로 만들므로, 요소가 객체이면 이전 상태와 같은 객체를 공유해 되돌린 값까지 함께 바뀝니다.

15함수 값을 반환하는 함수

함수를 만들어 돌려주지는 않지만, 함수를 값처럼 배열에 담아 reduce로 적용하는 모습을 보여 줍니다. 전략을 데이터로 다루면 실행 중에 목록을 바꿔 동작을 갈아 끼울 수 있습니다. 배열 리터럴 안의 화살표 함수는 문맥에서 타입을 얻지 못하면 매개변수가 암시적 any가 되므로 여기처럼 명시하는 편이 좋습니다.

TypeScript code

// 함수 값을 반환하는 함수
const steps = [(value: number) => value + 1, (value: number) => value * 2];
console.log(steps.reduce((value, step) => step(value), 15));
알아두면 좋은 점

함수를 데이터로 다루면 설정이나 조건에 따라 목록을 조립해, 코드를 고치지 않고도 동작 조합을 바꿀 수 있습니다.

자주 하는 실수

배열 리터럴 안 화살표 함수의 매개변수는 문맥 타입이 없으면 암묵적 any가 되어, 잘못된 값을 넘겨도 컴파일에서 걸리지 않습니다.

정리하며

  • 스프레드는 한 겹만 복사하므로 중첩 상태는 경로를 따라 층마다 새로 만듭니다.
  • readonly는 컴파일 타임 표시이며 런타임 변형은 막지 못한다고 전제합니다.
  • compose는 오버로드나 가변 튜플 제네릭 없이는 중간 타입이 뭉개집니다.
  • 실패는 예외 대신 Result 형태의 반환값으로 두어 호출부가 분기하게 만듭니다.

더 깊이 들어가고 싶다면 TypeScript 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.