개인 프로젝트에서 반복적으로 쓰이는 TypeScript 유틸리티 모음.
| 함수 |
시그니처 |
설명 |
chunk |
chunk<T>(arr: T[], size: number): T[][] |
배열을 n개씩 나눈다 |
compact |
compact<T>(arr: (T | null | undefined | false | 0 | "")[]): T[] |
falsy 값 제거 (타입에서도 제외) |
first |
first<T>(arr: T[]): T | undefined |
첫 번째 요소 반환 (빈 배열 → undefined) |
last |
last<T>(arr: T[]): T | undefined |
마지막 요소 반환 (빈 배열 → undefined) |
move |
move<T>(arr: T[], from: number, to: number): T[] |
요소를 from → to 인덱스로 이동 (비파괴) |
paginate |
paginate<T>(arr: T[], page: number, pageSize: number): PaginationResult<T> |
배열 페이지네이션 (data·total·totalPages·hasNext·hasPrev) |
toggle |
toggle<T>(arr: T[], item: T, keyFn?): T[] |
없으면 추가, 있으면 제거 (비파괴) |
countBy |
countBy<T, K>(arr: T[], keyFn: (item: T) => K): Partial<Record<K, number>> |
키 기준으로 등장 횟수 집계 |
difference |
difference<T>(a: T[], b: T[], keyFn?): T[] |
a에만 있는 요소 반환 (차집합) |
keyBy |
keyBy<T, K>(arr: T[], keyFn: (item: T) => K): Record<K, T> |
배열을 키 함수 기준 Record로 변환 (O(1) 조회용) |
maxBy |
maxBy<T>(arr: T[], keyFn: (item: T) => number): T | undefined |
keyFn 값이 가장 큰 요소 반환 |
minBy |
minBy<T>(arr: T[], keyFn: (item: T) => number): T | undefined |
keyFn 값이 가장 작은 요소 반환 |
partition |
partition<T>(arr: T[], predicate): [T[], T[]] |
predicate 기준으로 두 배열로 분리 (타입 가드 지원) |
sample |
sample<T>(arr: T[]): T | undefined |
배열에서 무작위로 한 요소 반환 |
sampleSize |
sampleSize<T>(arr: T[], n: number): T[] |
중복 없이 무작위로 n개 반환 |
shuffle |
shuffle<T>(arr: T[]): T[] |
Fisher-Yates 알고리즘으로 섞은 새 배열 반환 (비파괴) |
sum |
sum(arr: number[]): number |
숫자 배열의 합 |
sumBy |
sumBy<T>(arr: T[], keyFn: (item: T) => number): number |
keyFn으로 추출한 값들의 합 |
flatten |
flatten<T>(arr: T[], depth?: number): FlatArray<T[], number>[] |
중첩 배열 펼치기 (기본: 1단계) |
groupBy |
groupBy<T, K>(arr: T[], keyFn: (item: T) => K): Partial<Record<K, T[]>> |
키 추출 함수 기준으로 그룹핑 |
intersection |
intersection<T>(a: T[], b: T[], keyFn?): T[] |
양쪽 모두에 있는 요소 반환 (교집합) |
sortBy |
sortBy<T>(arr: T[], keyFn: (item: T) => string | number, order?: 'asc'|'desc'): T[] |
키 기준 정렬 (stable, 비파괴) |
tuple |
tuple<T extends unknown[]>(...args: T): T |
인자들을 튜플 타입으로 추론 |
unique |
unique<T>(arr: T[], keyFn?: (item: T) => unknown): T[] |
중복 제거 (첫 등장 순서 유지) |
zip |
zip<T extends unknown[][]>(...arrays: T): [...][] |
여러 배열을 인덱스 기준으로 묶음 (최단 길이 기준) |
windows |
windows<T>(arr: T[], size: number, options?): T[][] |
크기 size의 슬라이딩 윈도우 배열 (이동평균·n-gram 등) |
pairwise |
pairwise<T>(arr: T[]): [T, T][] |
인접한 두 요소의 쌍 배열 (windows(arr, 2) 축약형) |
rotate |
rotate<T>(arr: T[], n: number): T[] |
배열을 왼쪽(양수)/오른쪽(음수)으로 n칸 회전 (비파괴) |
unzip |
unzip<T extends readonly unknown[]>(pairs: T[]): { [K in keyof T]: T[K][] } |
튜플 배열 → 개별 배열들 (zip 역연산 / 행렬 전치) |
zipWith |
zipWith<A,B,R>(a: A[], b: B[], fn: (a:A,b:B)=>R): R[] |
zip + map 일괄 처리 — 두(또는 세) 배열을 결합 함수에 적용 |
scan |
scan<T, U>(arr: T[], initial: U, fn: (acc: U, item: T, index: number) => U): U[] |
누적 reduce — 매 단계의 중간 값을 배열로 반환 (누적 합·잔액 추적 등) |
orderBy |
orderBy<T>(arr: T[], keys: KeyFn<T>[], orders?: Order[]): T[] |
다중 키 정렬 — 키 우선순위 순서로 정렬, null/undefined는 항상 맨 뒤 (stable, 비파괴) |
binarySearch |
binarySearch<T>(arr: T[], value: T, compareFn?): number |
정렬된 배열에서 O(log n) 탐색 — 인덱스 반환, 없으면 -1 |
sortedIndex |
sortedIndex<T>(arr: T[], value: T, compareFn?): number |
정렬 유지 삽입 위치 (lower bound) — splice와 조합해 정렬 배열에 O(log n) 삽입 |
sortedLastIndex |
sortedLastIndex<T>(arr: T[], value: T, compareFn?): number |
정렬 유지 삽입 위치 (upper bound) — 중복 값의 오른쪽 경계 |
take |
take<T>(arr: T[], n: number): T[] |
앞에서 n개 반환 |
drop |
drop<T>(arr: T[], n: number): T[] |
앞에서 n개 제거한 나머지 반환 |
takeLast |
takeLast<T>(arr: T[], n: number): T[] |
뒤에서 n개 반환 |
dropLast |
dropLast<T>(arr: T[], n: number): T[] |
뒤에서 n개 제거한 나머지 반환 |
takeWhile |
takeWhile<T>(arr: T[], predicate: (item: T) => boolean): T[] |
predicate가 true인 동안 앞에서부터 수집 |
dropWhile |
dropWhile<T>(arr: T[], predicate: (item: T) => boolean): T[] |
predicate가 true인 동안 앞에서부터 건너뜀 |
import { chunk, compact, flatten, groupBy, tuple, zip, unzip, zipWith } from "simple-ts-tools";
chunk([1, 2, 3, 4, 5], 2);
// [[1, 2], [3, 4], [5]]
// falsy 값 제거 — 반환 타입에서 null/undefined/false/0/"" 자동 제외
compact([0, 1, false, 2, "", 3, null, undefined]);
// [1, 2, 3] (타입: number[])
const ids: (string | null)[] = ["a", null, "b", undefined, "c"];
const validIds: string[] = compact(ids); // null/undefined 제거, 타입 보장
// 중첩 배열 펼치기
flatten([1, [2, [3, [4]]]]); // [1, 2, [3, [4]]] — depth=1
flatten([1, [2, [3, [4]]]], 2); // [1, 2, 3, [4]]
flatten([1, [2, [3]]], Infinity); // [1, 2, 3] — 완전히 펼치기
// 여러 배열을 인덱스 기준으로 묶기 — 가장 짧은 배열 길이에 맞춤
zip([1, 2, 3], ["a", "b", "c"]); // [[1,"a"], [2,"b"], [3,"c"]]
zip([1, 2], ["a", "b", "c"], [true]); // [[1,"a",true]] — 길이 1
// API 응답의 keys/values를 합칠 때
const keys = ["id", "name", "age"];
const values = [1, "Alice", 30];
Object.fromEntries(zip(keys, values)); // { id: 1, name: "Alice", age: 30 }
groupBy([1, 2, 3, 4], x => x % 2 === 0 ? "even" : "odd");
// { odd: [1, 3], even: [2, 4] }
tuple(1, "hello", true);
// [number, string, boolean] — 튜플로 추론됨
unique([1, 2, 2, 3, 1]);
// [1, 2, 3]
unique(["React", "react", "Vue"], t => t.toLowerCase());
// ["React", "Vue"] — 대소문자 무시
unique(users, u => u.id);
// id 기준 첫 등장 객체만 유지
// 교집합 — 양쪽에 공통으로 존재하는 요소
intersection([1, 2, 3], [2, 3, 4]); // [2, 3]
intersection(usersA, usersB, u => u.id); // 공통 유저
intersection(userRoles, requiredRoles).length > 0; // 권한 체크
// 차집합 — 첫 번째에만 있는 요소
difference([1, 2, 3], [2, 3]); // [1]
difference(prev, next, item => item.id); // 삭제된 항목
const added = difference(next, prev); // 추가된 항목
const removed = difference(prev, next); // 삭제된 항목
sortBy(users, u => u.name); // 이름 오름차순
sortBy(users, u => u.name, "desc"); // 이름 내림차순
sortBy(items, i => -i.price); // 가격 내림차순 (부호 반전)
// orderBy — 다중 키 정렬 (sortBy는 단일 키)
// 점수 내림차순, 동점자는 이름 오름차순
orderBy(players, [p => p.score, p => p.name], ["desc", "asc"]);
// 부서 오름차순 → 직급 내림차순 → 이름 오름차순
orderBy(employees, [e => e.dept, e => e.level, e => e.name], ["asc", "desc", "asc"]);
// 할 일 — 우선순위 오름차순, 같으면 마감일 오름차순
orderBy(tasks, [t => t.priority, t => t.dueDate]);
// null/undefined는 방향 무관하게 항상 맨 뒤
orderBy(items, [x => x.value]); // null이 있어도 안전
// 배열 → Record 변환 (O(1) 조회)
const users = [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }];
const userMap = keyBy(users, u => u.id);
userMap[1]; // { id: 1, name: "Alice" }
userMap[2]; // { id: 2, name: "Bob" }
// API 응답 배열을 id 기준으로 색인화할 때 자주 사용
const postMap = keyBy(posts, p => p.slug); // O(n) 한 번, 이후 O(1) 조회
// 등장 횟수 집계 — groupBy의 카운트 버전
countBy(["a", "b", "a", "c", "b", "a"], x => x);
// { a: 3, b: 2, c: 1 }
countBy(users, u => u.role);
// { admin: 2, viewer: 5, editor: 1 }
countBy([1, 2, 3, 4, 5, 6], n => n % 2 === 0 ? "even" : "odd");
// { odd: 3, even: 3 }
// 조건으로 배열 둘로 분리 — filter 두 번 대신 한 번의 순회로 처리
const [active, inactive] = partition(users, u => u.isActive);
const [passed, failed] = partition(scores, s => s >= 60);
// 타입 가드로 타입 좁히기
const values: (string | number)[] = [1, "a", 2, "b"];
const [strings, nums] = partition(values, (v): v is string => typeof v === "string");
// strings: string[], nums: number[]
// 최솟값 / 최댓값 요소 찾기 — sort 없이 O(n)
minBy(products, p => p.price); // 가장 저렴한 상품
maxBy(articles, a => a.views); // 조회수 가장 높은 글
minBy(events, e => e.startAt.getTime()); // 가장 이른 일정
// 합산
sum([1, 2, 3, 4, 5]); // 15
sumBy(cart, item => item.price * item.qty); // 장바구니 총액
sumBy(tasks, t => t.estimatedHours); // 총 예상 시간
// 배열 무작위 셔플 (비파괴 — Fisher-Yates)
shuffle([1, 2, 3, 4, 5]); // [3, 1, 5, 2, 4] (무작위)
// 무작위 1개 선택
sample(["A", "B", "C", "D"]); // "B" (무작위)
// 중복 없이 n개 선택
sampleSize([1, 2, 3, 4, 5], 3); // [4, 1, 3] (무작위 3개)
sampleSize(questions, 5); // 시험 문제 랜덤 출제
// 안전한 first / last (빈 배열에서도 throw 없음)
first([1, 2, 3]); // 1
last([1, 2, 3]); // 3
first([]); // undefined
last([]); // undefined
// drag-and-drop 순서 변경 — 비파괴
const tasks = ["Task A", "Task B", "Task C", "Task D"];
move(tasks, 2, 0); // ["Task C", "Task A", "Task B", "Task D"]
move(tasks, 0, 3); // ["Task B", "Task C", "Task D", "Task A"]
// 멀티셀렉트 토글 — 있으면 제거, 없으면 추가
toggle(["react", "typescript"], "vue"); // ["react", "typescript", "vue"]
toggle(["react", "typescript"], "react"); // ["typescript"]
// 객체 배열 토글 — keyFn으로 비교 기준 지정
const selected = [{ id: 1 }, { id: 2 }];
toggle(selected, { id: 2 }, x => x.id); // [{ id: 1 }] (제거)
toggle(selected, { id: 3 }, x => x.id); // [..., { id: 3 }] (추가)
// windows — 슬라이딩 윈도우 (chunk는 비겹침, windows는 겹침)
windows([1, 2, 3, 4, 5], 3);
// [[1,2,3], [2,3,4], [3,4,5]]
windows([1, 2, 3, 4, 5], 3, { step: 2 });
// [[1,2,3], [3,4,5]]
// 이동평균 (3-period MA) — 차트, 시계열 스무딩
const prices = [10, 12, 11, 14, 13, 15];
const ma3 = windows(prices, 3).map(w => w.reduce((a, b) => a + b) / w.length);
// [11, 12.33, 12.67, 14]
// n-gram 생성 — 텍스트 분석, 검색 자동완성
const tokens = ["I", "love", "TypeScript"];
windows(tokens, 2).map(pair => pair.join(" "));
// ["I love", "love TypeScript"]
// 연속 이벤트 패턴 감지 — 퍼널 분석
const events = ["view", "click", "view", "click", "purchase"];
windows(events, 3).filter(w => w[2] === "purchase");
// [["view", "click", "purchase"]]
// pairwise — 인접 쌍 (windows(arr, 2) 축약)
pairwise([100, 110, 105, 120]).map(([prev, curr]) =>
Math.round((curr - prev) / prev * 100)
); // [10, -5, 14] — 가격 변동률
// 연속 이벤트 간 시간 간격
pairwise(timestamps).map(([a, b]) => b - a);
// rotate — 배열 순환 이동 (비파괴)
rotate([1, 2, 3, 4, 5], 2) // [3, 4, 5, 1, 2] — 왼쪽으로 2칸
rotate([1, 2, 3, 4, 5], -1) // [5, 1, 2, 3, 4] — 오른쪽으로 1칸
rotate([1, 2, 3], 100) // [2, 3, 1] — 자동 정규화 (100 % 3 = 1)
// 캐러셀 슬라이드 — 다음/이전
const next = rotate(slides, 1); // 첫 슬라이드가 끝으로
const prev = rotate(slides, -1); // 마지막 슬라이드가 앞으로
// 라운드로빈 담당자 배정
const nextRound = rotate(workers, currentTurn + 1);
nextRound[0]; // 다음 담당자
// unzip — 튜플 배열을 개별 배열로 분리 (zip 역연산)
const pairs = [[1, "a"], [2, "b"], [3, "c"]] as [number, string][];
const [nums, strs] = unzip(pairs);
// nums: [1, 2, 3] strs: ["a", "b", "c"]
// Object.entries 키/값 분리
const [keys, values] = unzip(Object.entries({ a: 1, b: 2, c: 3 }) as [string, number][]);
// keys: ["a","b","c"] values: [1, 2, 3]
// 행렬 전치 (transpose)
unzip([[1, 2, 3], [4, 5, 6]]);
// [[1,4], [2,5], [3,6]]
// zipWith — zip + map 일괄 처리 (배열 생성 없이 바로 결합)
zipWith([1, 2, 3], [4, 5, 6], (a, b) => a + b); // [5, 7, 9] 벡터 덧셈
zipWith([1, 2, 3], [4, 5, 6], (a, b) => a * b); // [4,10,18] 벡터 곱
// 세 배열 동시 결합
zipWith([1, 2], [3, 4], [5, 6], (a, b, c) => a + b + c); // [9, 12]
// 레이블 생성 — 이름 + 점수
const labels = zipWith(["Alice","Bob"], [95,80], (name, score) => `${name}: ${score}`);
// ["Alice: 95", "Bob: 80"]
// take / drop — 앞에서 자르기
take([1, 2, 3, 4, 5], 3); // [1, 2, 3]
drop([1, 2, 3, 4, 5], 2); // [3, 4, 5]
takeLast([1, 2, 3, 4, 5], 2); // [4, 5]
dropLast([1, 2, 3, 4, 5], 2); // [1, 2, 3]
// takeWhile / dropWhile — 조건이 만족되는 동안
takeWhile([1, 2, 3, 4, 1], x => x < 3); // [1, 2]
dropWhile([1, 2, 3, 4, 1], x => x < 3); // [3, 4, 1]
// 실사용: 조건 충족 전까지의 로그 수집
const errorsBeforeTimeout = takeWhile(logs, log => log.level !== "fatal");
// 초기 로딩 스피너 이후의 이벤트만 처리
const eventsAfterReady = dropWhile(events, e => e.type !== "ready");
// 실사용: 최신 5개 알림만 표시
take(notifications.reverse(), 5);
// 첫 번째 페이지를 제외하고 나머지 로드
drop(allPages, 1);
// 페이지네이션 — 리스트 UI에서 매번 직접 계산하던 것을 한 번에
const posts = Array.from({ length: 53 }, (_, i) => ({ id: i + 1 }));
const page1 = paginate(posts, 1, 10);
// { data: [{id:1},...,{id:10}], total: 53, page: 1, pageSize: 10,
// totalPages: 6, hasNext: true, hasPrev: false }
const page6 = paginate(posts, 6, 10);
// { data: [{id:51},{id:52},{id:53}], hasNext: false, hasPrev: true }
// 실사용: React 컴포넌트
const { data, totalPages, hasNext, hasPrev } = paginate(allItems, currentPage, 20);
// binarySearch — 정렬된 배열에서 O(log n) 탐색 (Array.indexOf는 O(n))
binarySearch([1, 3, 5, 7, 9], 5) // 2
binarySearch([1, 3, 5, 7, 9], 4) // -1 (없음)
binarySearch(["a", "b", "c"], "b") // 1
// 객체 배열 — 커스텀 비교 함수
const products = [{ price: 10 }, { price: 25 }, { price: 50 }, { price: 100 }];
binarySearch(products, { price: 25 }, (a, b) => a.price - b.price); // 1
// sortedIndex — 정렬 유지 삽입 (lower bound)
sortedIndex([1, 3, 5, 7], 4) // 2 → [1, 3, _4_, 5, 7]
sortedIndex([1, 3, 3, 5], 3) // 1 → 첫 번째 3의 위치
// 정렬된 배열에 값 삽입
const sorted = [1, 3, 5, 7, 9];
sorted.splice(sortedIndex(sorted, 4), 0, 4);
// [1, 3, 4, 5, 7, 9]
// sortedLastIndex + sortedIndex — 중복 범위 슬라이스
const arr = [1, 2, 3, 3, 3, 4, 5];
arr.slice(sortedIndex(arr, 3), sortedLastIndex(arr, 3)); // [3, 3, 3]
| 함수 |
시그니처 |
설명 |
createDeferred |
createDeferred<T>(): Deferred<T> |
외부에서 resolve/reject 가능한 Promise 객체 생성 |
mapAsync |
mapAsync<T, R>(arr: T[], fn, options?): Promise<R[]> |
동시성 제한 병렬 처리 (기본: 제한 없음) |
parallel |
parallel(fns: AsyncFn[], options?): Promise<[...]> |
서로 다른 N개의 비동기 함수를 동시에 실행, 결과를 튜플로 반환 (최대 8개 타입 추론) |
memoizeAsync |
memoizeAsync<TArgs, TReturn>(fn, options?): MemoizedFn |
비동기 함수 캐싱 (TTL·maxSize·thundering herd 방지) |
retry |
retry<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T> |
실패 시 지수 백오프로 재시도 |
sleep |
sleep(ms: number): Promise<void> |
지정한 시간(ms)만큼 대기 |
timeout |
timeout<T>(promise: Promise<T>, ms: number, message?: string): Promise<T> |
타임아웃 초과 시 reject |
createBatch |
createBatch<K, V>(batchFn, options?): Batcher<K, V> |
DataLoader 패턴 — 같은 틱의 load(key) 호출을 자동으로 묶어 단일 배치 함수로 처리 |
poll |
poll<T>(fn, predicate, options?): Promise<T> |
조건이 충족될 때까지 비동기 함수를 반복 호출 (retry는 실패 재시도, poll은 정상 응답 검사) |
pLimit |
pLimit(concurrency): Limiter |
동시에 실행할 비동기 작업 수를 제한하는 concurrency limiter — activeCount/pendingCount/clearQueue() 제공 |
createRateLimiter |
createRateLimiter(options): RateLimiter |
토큰 버킷 기반 시간당 호출 수 제한 — acquire()/tryAcquire()/reset() 제공 |
createCircuitBreaker |
createCircuitBreaker(fn, options?): CircuitBreaker |
서킷 브레이커 — 연속 실패 시 OPEN 전환 후 복구 탐색 (CLOSED → OPEN → HALF_OPEN) |
createAsyncQueue |
createAsyncQueue<T>(options?): AsyncQueue<T> |
비동기 producer/consumer 큐 — backpressure, for await...of, 멀티 consumer 지원 |
createScheduler |
createScheduler(): Scheduler |
주기적 작업 스케줄러 — drift 방지, 중복 실행 차단, 에러 격리, 개별/전체 start·stop |
MemoizeAsyncOptions
| 옵션 |
타입 |
기본값 |
설명 |
ttl |
number |
— |
캐시 만료 시간 (ms). 미지정 시 영구 캐시 |
maxSize |
number |
— |
최대 캐시 항목 수. 초과 시 FIFO 제거 |
keyFn |
(...args) => string |
JSON.stringify |
캐시 키 생성 함수 |
TaskOptions (scheduler.every의 세 번째 인자)
| 옵션 |
타입 |
기본값 |
설명 |
id |
string |
자동 생성 |
작업 식별자 (scheduler.tasks.get(id)로 조회) |
exclusive |
boolean |
true |
이전 실행이 끝나지 않으면 다음 실행 건너뜀. false이면 동시 실행 허용 |
runImmediately |
boolean |
false |
등록 즉시 한 번 실행 (첫 interval을 기다리지 않음) |
onError |
(error, taskId) => void |
— |
실행 중 에러 발생 시 호출. 콜백이 없으면 에러를 조용히 무시 (스케줄러는 계속 동작) |
ScheduledTask 메서드·프로퍼티
|
설명 |
start() |
스케줄 시작·재개 |
stop() |
스케줄 중단 (실행 중인 작업은 완료될 때까지 기다림) |
run() |
스케줄과 무관하게 즉시 한 번 실행 |
isScheduled |
스케줄 활성화 여부 |
isRunning |
현재 실행 중인지 |
lastRunAt |
직전 실행 완료 시각 (Date | null) |
lastError |
직전 실행 에러 |
runCount |
총 실행 횟수 |
AsyncQueueOptions
| 옵션 |
타입 |
기본값 |
설명 |
capacity |
number |
무제한 |
버퍼 최대 크기. 초과 시 push()가 소비될 때까지 대기한다 (backpressure) |
CircuitBreakerOptions
| 옵션 |
타입 |
기본값 |
설명 |
threshold |
number |
5 |
OPEN으로 전환될 연속 실패 횟수 |
resetTimeout |
number |
60_000 |
OPEN → HALF_OPEN 전환 대기 시간 (ms) |
successThreshold |
number |
1 |
HALF_OPEN에서 CLOSED 복귀에 필요한 연속 성공 횟수 |
isFailure |
(error: unknown) => boolean |
() => true |
실패 여부 판단 함수 (특정 에러 제외 가능) |
onStateChange |
(event: StateChangeEvent) => void |
— |
상태 전이 시 호출되는 콜백 |
RateLimiterOptions
| 옵션 |
타입 |
설명 |
limit |
number |
시간 창(window) 내 최대 허용 횟수 (양의 정수) |
window |
number |
시간 창 크기 (ms). window/limit ms마다 토큰 1개 보충 |
RetryOptions
| 옵션 |
타입 |
기본값 |
설명 |
attempts |
number |
3 |
최대 시도 횟수 |
delay |
number |
200 |
첫 재시도 대기 시간 (ms) |
backoff |
number |
2 |
재시도마다 delay에 곱할 배수 |
when |
(error: unknown) => boolean |
— |
재시도 조건 함수 |
import { mapAsync, sleep, retry } from "simple-ts-tools";
// 동시성 제한 병렬 처리 — 외부 API rate limit 대응
const users = await mapAsync(
userIds,
id => fetchUser(id),
{ concurrency: 3 } // 최대 3개 동시 실행
);
// concurrency 미지정 = Promise.all과 동일
const results = await mapAsync(items, item => processItem(item));
// index 활용
const indexed = await mapAsync(rows, async (row, i) => ({ ...row, rank: i + 1 }));
// 300ms 대기
await sleep(300);
// 네트워크 요청 재시도 (200ms → 400ms → 800ms)
const data = await retry(
() => fetch("/api/data").then(r => r.json()),
{ attempts: 3, delay: 200 }
);
// 5xx 에러만 재시도, 4xx는 즉시 throw
await retry(() => callApi(), {
when: (e) => (e as Response).status >= 500,
});
// 타임아웃 — 지정 시간 내 완료 안 되면 reject
const data = await timeout(fetch("/api/slow"), 3000);
await timeout(heavyJob(), 5000, "처리 시간 초과");
// retry와 조합 — 타임아웃 걸린 요청도 재시도
await retry(() => timeout(fetchData(), 2000), { attempts: 3 });
// Deferred — 외부에서 제어 가능한 Promise
const ready = createDeferred<void>();
// 이벤트 기반 코드를 Promise로 변환
const loaded = createDeferred<string>();
image.onload = () => loaded.resolve(image.src);
image.onerror = (e) => loaded.reject(e);
const src = await loaded.promise;
// 두 비동기 흐름 사이 핸드셰이크
const serverReady = createDeferred<void>();
server.on("listen", () => serverReady.resolve());
await serverReady.promise;
// 이제 서버가 준비됨을 보장하고 다음 단계 진행
// 상태 확인
ready.status; // "pending" | "fulfilled" | "rejected"
// 중복 호출 안전 — resolve/reject 이후 추가 호출은 무시됨
ready.resolve();
ready.resolve(); // no-op
// memoizeAsync — 비동기 함수 캐싱
const getUser = memoizeAsync(fetchUser, { ttl: 60_000 });
await getUser(1); // 네트워크 요청
await getUser(1); // 60초 내 → 캐시 반환 (fn 재호출 없음)
// parallel — 서로 다른 비동기 함수들을 동시에 실행
// mapAsync: 배열의 각 항목을 하나의 함수로 처리 (항목 N개, 함수 1개)
// parallel: 서로 다른 함수들을 동시에 실행 (항목 N개, 함수 N개)
const [user, posts, config] = await parallel([
() => fetchUser(id),
() => fetchPosts(id),
() => fetchAppConfig(),
]);
// 각 반환 타입이 정확히 추론됨: user: User, posts: Post[], config: AppConfig
// concurrency 제한 — API rate limit 준수
const [a, b, c, d] = await parallel(
[() => callApi("a"), () => callApi("b"), () => callApi("c"), () => callApi("d")],
{ concurrency: 2 } // 2개씩 순차적으로 처리
);
// 실사용: 페이지 초기 데이터 로딩
async function loadDashboard(userId: string) {
const [profile, stats, notifications] = await parallel([
() => api.getProfile(userId),
() => api.getStats(userId),
() => api.getNotifications(userId),
]);
return { profile, stats, notifications };
}
// 실사용: 외부 서비스 호출 (rate limit 있을 때)
const enrichedItems = await parallel(
items.map(item => () => enrichWithExternalData(item)),
{ concurrency: 3 }
);
// maxSize: LRU-like 항목 수 제한
const getProduct = memoizeAsync(fetchProduct, { ttl: 30_000, maxSize: 100 });
// thundering herd 자동 방지
// 같은 키 동시 호출 → 단 한 번만 실행, 결과는 모두에게 공유
const [a, b, c] = await Promise.all([getUser(1), getUser(1), getUser(1)]);
// fetchUser는 한 번만 호출됨
// 특정 키 무효화 (예: 데이터 수정 후)
await updateUser(1, newData);
getUser.invalidate(1); // 다음 호출 시 재조회
// 전체 캐시 초기화
getUser.clear();
// keyFn 커스터마이즈 — 역할에 무관하게 id 기준 캐싱
const getPermissions = memoizeAsync(fetchPermissions, {
keyFn: (user) => user.id,
ttl: 5 * 60_000,
});
BatchOptions
| 옵션 |
타입 |
기본값 |
설명 |
maxSize |
number |
100 |
한 배치의 최대 키 수. 초과 시 즉시 플러시 |
maxWait |
number |
0 |
배치 수집 대기 시간(ms). 0이면 같은 틱 마이크로태스크에서 플러시 |
batchFn은 반드시 입력 keys 배열과 동일한 길이·순서의 결과(V | Error) 배열을 반환해야 한다.
개별 항목에 Error 인스턴스를 반환하면 해당 키만 reject된다.
import { createBatch } from "simple-ts-tools";
// N+1 쿼리 방지 — 여러 곳에서 각각 load()해도 DB 호출은 1번
const userLoader = createBatch(async (ids: number[]) => {
const users = await db.users.findMany({ where: { id: { in: ids } } });
// 반드시 ids와 동일한 길이·순서로 반환
return ids.map(id => users.find(u => u.id === id) ?? new Error(`User ${id} not found`));
});
// 세 컴포넌트가 독립적으로 요청 → batchFn 호출은 1번
const [alice, bob, charlie] = await Promise.all([
userLoader.load(1),
userLoader.load(2),
userLoader.load(3),
]);
// 특정 키가 없으면 해당 Promise만 reject, 나머지는 정상 처리
const results = await Promise.allSettled([
userLoader.load(1), // fulfilled
userLoader.load(99), // rejected (not found)
]);
// maxSize — API rate limit 대응: 50개씩 묶어 요청
const priceLoader = createBatch(
async (symbols: string[]) => fetchPrices(symbols),
{ maxSize: 50 }
);
// maxWait — 10ms 동안 키를 모은 뒤 배치 처리 (다른 틱의 호출도 묶기)
const logLoader = createBatch(
async (ids: string[]) => bulkFetchLogs(ids),
{ maxWait: 10 }
);
PollOptions
| 옵션 |
타입 |
기본값 |
설명 |
interval |
number |
1000 |
시도 간격 (ms) |
timeout |
number |
— |
최대 대기 시간 (ms). 초과 시 PollTimeoutError 발생. 미지정 시 무제한 |
onAttempt |
(attempt: number) => void |
— |
매 시도 전 호출 (진행률 UI 업데이트 등) |
retry vs poll 비교:
retry: fn이 throw 하면 재시도 (네트워크 오류 등 예외 상황 대응)
poll: fn이 정상 값을 반환하지만 원하는 조건이 아닐 때 재시도 (상태 변화 대기)
import { pLimit } from "simple-ts-tools";
// API 호출을 최대 3개씩 병렬 처리 (rate limit 준수)
const limit = pLimit(3);
const results = await Promise.all(
urls.map(url => limit(() => fetch(url).then(r => r.json())))
);
// 파일 처리 — 한 번에 5개씩만 열기
const limit = pLimit(5);
await Promise.all(files.map(f => limit(() => processFile(f))));
// 진행 상황 모니터링
console.log(limit.activeCount); // 현재 실행 중인 작업 수
console.log(limit.pendingCount); // 대기 중인 작업 수
// 긴급 취소 — 남은 큐 비우기 (실행 중인 작업은 그대로 완료)
limit.clearQueue();
import { createScheduler } from "simple-ts-tools";
const scheduler = createScheduler();
// DB 정리 작업 — 5분마다, 이전 실행이 끝나야 다음 실행 (exclusive: true 기본값)
scheduler.every(5 * 60_000, () => db.cleanup(), {
id: "db-cleanup",
onError: (err, taskId) => logger.error(`${taskId} 실패`, {}, err),
});
// 헬스 체크 — 30초마다, 등록 즉시 한 번 실행
scheduler.every(30_000, checkHealth, {
id: "health-check",
runImmediately: true,
onError: (err) => alertOncall(err),
});
// 통계 집계 — 1분마다, 동시 실행 허용 (exclusive: false)
scheduler.every(60_000, generateStats, { exclusive: false });
// 개별 작업 제어
const task = scheduler.tasks.get("db-cleanup")!;
task.stop(); // 일시 중지
task.start(); // 재개
await task.run(); // 즉시 한 번 실행 (스케줄과 무관)
console.log(task.runCount); // 총 실행 횟수
console.log(task.lastRunAt); // 직전 실행 완료 시각
console.log(task.lastError); // 직전 에러
// 전체 제어 (서버 셧다운 등)
scheduler.stopAll();
scheduler.startAll();
import { createAsyncQueue } from "simple-ts-tools";
// 워커 패턴 — producer와 consumer를 독립적으로 실행
const queue = createAsyncQueue<Buffer>({ capacity: 10 });
// Producer (별도 async context에서 실행)
(async () => {
for (const chunk of readChunks()) {
await queue.push(chunk); // capacity=10 초과 시 자동 backpressure
}
queue.close();
})();
// Consumer — for await...of로 큐 닫힐 때까지 자동 소비
for await (const chunk of queue) {
await processChunk(chunk);
}
// 멀티 consumer — 4개 워커가 동일 큐를 경쟁 소비
const jobQueue = createAsyncQueue<Job>();
const workers = Array.from({ length: 4 }, () =>
(async () => { for await (const job of jobQueue) await processJob(job); })()
);
await Promise.all(workers);
// 파이프라인 — 큐를 transform stage로 연결
const rawQ = createAsyncQueue<string>({ capacity: 50 });
const parsedQ = createAsyncQueue<ParsedItem>({ capacity: 50 });
// Stage 1: raw → parsed
(async () => {
for await (const line of rawQ) await parsedQ.push(parse(line));
parsedQ.close();
})();
// Stage 2: parsed → DB
for await (const item of parsedQ) await db.insert(item);
// 상태 확인
console.log(queue.size); // 버퍼에 있는 항목 수
console.log(queue.closed); // 큐 닫힘 여부
import { createCircuitBreaker, CircuitOpenError } from "simple-ts-tools";
// 외부 API 호출을 서킷 브레이커로 보호
const breaker = createCircuitBreaker(fetchUser, {
threshold: 5, // 5번 연속 실패 시 OPEN
resetTimeout: 10_000, // 10초 후 HALF_OPEN (복구 탐색)
successThreshold: 2, // 2번 연속 성공 시 CLOSED 복귀
});
// 정상 호출 — 투명하게 래핑
const user = await breaker.call("user-123");
// OPEN 상태에서는 즉시 CircuitOpenError (실제 함수 미호출)
try {
const user = await breaker.call("user-456");
} catch (e) {
if (e instanceof CircuitOpenError) {
console.log("서킷 오픈 — 나중에 재시도");
return cachedFallback(); // 폴백 처리
}
throw e;
}
// 특정 에러(404)는 실패로 간주하지 않음
const breaker2 = createCircuitBreaker(fetchResource, {
threshold: 3,
isFailure: (e) => !(e instanceof NotFoundError),
});
// 상태 변화 모니터링
const breaker3 = createCircuitBreaker(fn, {
onStateChange: ({ from, to }) => metrics.record("circuit_state", { from, to }),
});
// 상태 확인 및 강제 초기화
console.log(breaker.state); // "CLOSED" | "OPEN" | "HALF_OPEN"
console.log(breaker.failures); // 현재 연속 실패 횟수
breaker.reset(); // 강제로 CLOSED 복귀 (운영 대응)
import { createRateLimiter } from "simple-ts-tools";
// 초당 10회 호출 제한 (100ms마다 토큰 1개 보충)
const rate = createRateLimiter({ limit: 10, window: 1000 });
// API 클라이언트에서 rate limit 준수
async function fetchUser(id: string) {
await rate.acquire(); // 토큰 없으면 자동 대기
return fetch(`/api/users/${id}`);
}
// 토큰이 없으면 대기 없이 즉시 false 반환 (non-blocking 체크)
if (!rate.tryAcquire()) {
console.log("rate limit 초과, 나중에 재시도");
}
// pLimit과 조합 — 동시성(concurrency) + 시간당 호출 수(rate) 동시 제어
const concurrency = pLimit(5);
const rate2 = createRateLimiter({ limit: 20, window: 1000 });
await Promise.all(ids.map(id =>
concurrency(() => rate2.acquire().then(() => fetchUser(id)))
));
// 상태 확인
console.log(rate.tokens); // 현재 사용 가능한 토큰 수
console.log(rate.waiting); // 큐에서 대기 중인 요청 수
// 버킷 즉시 초기화 (토큰 가득 채우고 대기 큐 즉시 드레인)
rate.reset();
import { poll, PollTimeoutError } from "simple-ts-tools";
// 백그라운드 잡 완료 대기
const job = await poll(
() => fetch(`/api/jobs/${jobId}`).then(r => r.json()),
result => result.status === "done",
{ interval: 2000, timeout: 60_000 }
);
// 서버 헬스 체크 — ready 상태까지 1초마다 확인 (최대 30초)
await poll(
() => fetch("/health").then(r => r.json()),
res => res.status === "ok",
{ interval: 1000, timeout: 30_000 }
);
// 배포 완료 대기 — onAttempt로 진행 상황 로깅
await poll(
() => getDeployStatus(deployId),
s => s.phase === "Running",
{
interval: 5000,
timeout: 5 * 60_000,
onAttempt: n => console.log(`Checking deployment... (attempt ${n})`),
}
);
// timeout 초과 시 PollTimeoutError 처리
try {
await poll(checkQueue, q => q.length === 0, { timeout: 10_000 });
} catch (e) {
if (e instanceof PollTimeoutError) {
console.error(`${e.attempts}번 시도 후 타임아웃 (${e.elapsedMs}ms)`);
}
}
| 클래스/함수 |
설명 |
createStore<T> |
구조화된 객체 상태 + selector 구독 (슬라이스가 바뀔 때만 알림) |
BehaviorSubject<T> |
단일 값을 보유하며 변경 시 구독자에게 알리는 반응형 상태 홀더 |
TypedEventEmitter<TEvents> |
이벤트 이름과 페이로드 타입이 컴파일 타임에 검증되는 pub/sub |
createStore vs BehaviorSubject
|
createStore |
BehaviorSubject |
| 상태 형태 |
구조화된 객체 (여러 키) |
단일 값 |
| 부분 업데이트 |
set(partial) 병합 |
update(fn) 전체 교체 |
| 슬라이스 구독 |
select(fn) — 해당 슬라이스만 변경 시 호출 |
없음 (전체 값 변경 시 항상 호출) |
| 동등 비교 |
각 키별 Object.is |
Object.is (전체 값) |
Store 메서드
| 메서드 |
설명 |
get() |
현재 상태 동기 반환 |
set(partial) |
Partial 병합. 변경된 키가 없으면 구독자 호출 안 함 |
replace(state) |
전체 교체 |
update(fn) |
fn(state) 반환값을 병합 |
subscribe(listener) |
전체 상태 구독. 즉시 현재 상태 전달. 해제 함수 반환 |
select(selector, options?) |
파생 뷰 반환. selector 값이 바뀔 때만 listener 호출 |
reset() |
초기 상태로 복원 |
| 메서드 |
설명 |
.on(event, handler) |
핸들러 등록 |
.once(event, handler) |
한 번만 실행되는 핸들러 등록 |
.off(event, handler) |
핸들러 제거 |
.emit(event, payload) |
이벤트 발행 |
.clear(event?) |
특정 이벤트(또는 전체) 핸들러 제거 |
.listenerCount(event) |
등록된 핸들러 수 |
모든 메서드는 this를 반환하여 체이닝 가능.
BehaviorSubject — 현재 값 보유 + 구독자 알림
| 메서드 / 프로퍼티 |
설명 |
.getValue() |
현재 값 동기 반환 |
.set(value) |
새 값 설정, 동일값이면 무시 |
.update(fn) |
현재 값 기반 업데이트 |
.subscribe(handler) |
구독 등록 (즉시 현재 값 전달), 해제 함수 반환 |
.complete() |
완료 처리, 이후 set/update 무시 |
.subscriberCount |
현재 구독자 수 |
import { createStore } from "simple-ts-tools";
interface AppState {
user: { name: string; role: "admin" | "user" } | null;
theme: "light" | "dark";
notifications: number;
}
const store = createStore<AppState>({
user: null,
theme: "light",
notifications: 0,
});
// 부분 업데이트 — 나머지 키는 유지
store.set({ theme: "dark" });
store.update(s => ({ notifications: s.notifications + 1 }));
// 전체 상태 구독
const unsub = store.subscribe(state => console.log(state));
// selector — notifications가 바뀔 때만 호출 (theme 변경 시 호출 안 됨)
const badge = store.select(s => s.notifications);
badge.subscribe(count => updateBadge(count));
badge.get(); // 현재 값 동기 조회
// 파생 계산 — isAdmin은 user.role이 "admin"일 때만 true
const isAdmin = store.select(s => s.user?.role === "admin");
// 커스텀 equals — 객체 슬라이스를 값으로 비교
const userSlice = store.select(s => s.user, {
equals: (a, b) => a?.name === b?.name && a?.role === b?.role,
});
// 인증 상태 관리
store.set({ user: { name: "Alice", role: "admin" }, notifications: 0 });
store.set({ user: null }); // 로그아웃
store.reset(); // 초기 상태 복원
import { BehaviorSubject } from "simple-ts-tools";
// 간단한 카운터 상태
const count$ = new BehaviorSubject(0);
const unsub = count$.subscribe(v => console.log(v)); // 즉시 0 출력
count$.set(1); // 1 출력
count$.update(v => v + 1); // 2 출력
unsub(); // 구독 해제
// 객체 상태 관리
type State = { user: string | null; loading: boolean };
const state$ = new BehaviorSubject<State>({ user: null, loading: false });
state$.update(s => ({ ...s, loading: true }));
state$.update(s => ({ ...s, user: "Alice", loading: false }));
import { TypedEventEmitter } from "simple-ts-tools";
type AppEvents = {
userLogin: { userId: string; timestamp: number };
userLogout: { userId: string };
error: Error;
};
const emitter = new TypedEventEmitter<AppEvents>();
// 이벤트 이름, 페이로드 타입 모두 자동 추론
emitter.on("userLogin", ({ userId, timestamp }) => {
console.log(`${userId} logged in at ${timestamp}`);
});
emitter.once("error", (err) => console.error(err.message));
emitter
.emit("userLogin", { userId: "u1", timestamp: Date.now() })
.emit("userLogout", { userId: "u1" });
| 함수 |
시그니처 |
설명 |
compose |
compose(...fns): (a: A) => R |
함수들을 오른쪽→왼쪽으로 합성해 재사용 가능한 변환 함수 반환 (최대 8단계 타입 안전) |
curry |
curry(fn): CurriedFn |
함수를 커리화 — 인자를 하나씩 받아 마지막까지 받으면 실행 (2~4인자 완전 타입 추론) |
partial |
partial(fn, ...boundArgs): (...remaining) => R |
앞쪽 인자를 미리 바인딩한 특화 함수 생성 (2~5인자 완전 타입 추론) |
debounce |
debounce<T>(fn: T, wait: number): T & { cancel() } |
마지막 호출 후 wait ms 뒤에 실행 (trailing-edge) |
memoize |
memoize<TArgs, TReturn>(fn, keyFn?): fn & { cache: Map; clear() } |
인자 기준으로 결과 캐싱 |
once |
once<TArgs, TReturn>(fn): fn & { reset() } |
최초 한 번만 실행, 이후 호출은 첫 결과 반환 |
pipe |
pipe(value, ...fns): T |
값을 함수들에 왼쪽→오른쪽으로 순서대로 통과 (최대 8단계 타입 안전) |
pipeAsync |
pipeAsync(value, ...fns): Promise<T> |
pipe의 비동기 버전 — 동기·비동기 함수 혼합 가능, 각 단계의 Promise를 순서대로 await |
throttle |
throttle<T>(fn: T, interval: number): T & { cancel() } |
interval ms 내 최대 한 번 실행 (leading-edge + trailing) |
negate |
negate<T>(fn: (...args: T) => boolean): (...args: T) => boolean |
술어 함수의 결과를 반전시킨 새 함수 반환 (!fn(...)) |
tap |
tap<T>(fn: (value: T) => void): (value: T) => T |
pipe/compose 체인에서 값을 변경하지 않고 부수 효과 실행 (로깅·분석·디버깅) |
createStateMachine |
createStateMachine<S, E>(config): StateMachine<S, E> |
타입 안전 유한 상태 기계 — 정의된 전이만 허용, 허용 안 된 이벤트는 무시 |
import { curry, debounce, throttle, pipe } from "simple-ts-tools";
// compose — 재사용 가능한 변환 함수 조립 (오른쪽 → 왼쪽)
const double = (n: number) => n * 2;
const addOne = (n: number) => n + 1;
const square = (n: number) => n * n;
compose(double, addOne, square)(3);
// square(3)=9 → addOne(9)=10 → double(10)=20
// pipe와의 차이:
// pipe(3, square, addOne, double) → 값을 즉시 통과 (일회성)
// compose(double, addOne, square) → 재사용 가능한 함수 반환
// .map()과 함께 사용 — 핵심 강점
[1, 2, 3].map(compose(double, addOne)); // [4, 6, 8]
// 재사용 가능한 정규화 파이프라인
const normalizeUsername = compose(
(s: string) => s.replace(/[^a-z0-9]/g, ""),
(s: string) => s.toLowerCase(),
(s: string) => s.trim(),
);
users.map(u => ({ ...u, username: normalizeUsername(u.username) }));
// 커링과 조합 — 설정 가능한 변환 합성
const clamp = (max: number) => (n: number) => Math.min(n, max);
const round2 = (n: number) => Math.round(n * 100) / 100;
const processPrice = compose(clamp(999.99), round2);
prices.map(processPrice);
// partial — 앞쪽 인자를 미리 바인딩 (curry와의 차이: 여러 인자를 한 번에)
// curry: add(1)(2)(3) — 한 번에 하나씩
// partial: partial(add, 1)(2, 3) — 여러 개를 한 번에 바인딩
const clamp = (min: number, max: number, v: number) =>
Math.min(Math.max(v, min), max);
const clamp0to100 = partial(clamp, 0, 100);
clamp0to100(150); // 100
clamp0to100(-10); // 0
clamp0to100(50); // 50
// .map()과 함께 사용 — 특화 함수를 콜백으로 직접 전달
const rawScores = [120, 85, -5, 60, 105];
rawScores.map(clamp0to100); // [100, 85, 0, 60, 100]
// API 호출 함수에 공통 설정 바인딩
const request = (baseUrl: string, headers: Record<string, string>, path: string) =>
fetch(`${baseUrl}${path}`, { headers });
const apiCall = partial(request, "https://api.example.com", { Authorization: "Bearer token" });
apiCall("/users"); // GET https://api.example.com/users
apiCall("/posts"); // GET https://api.example.com/posts
// 정렬 기준을 미리 바인딩
const compareBy = (key: string, a: Record<string, number>, b: Record<string, number>) =>
a[key] - b[key];
const compareByAge = partial(compareBy, "age");
users.sort(compareByAge);
// 검색창 — 입력 멈춘 300ms 후 API 호출
const search = debounce((q: string) => fetchResults(q), 300);
input.addEventListener("input", e => search(e.currentTarget.value));
search.cancel(); // 예약 취소
// 스크롤 핸들러 — 100ms마다 최대 한 번
const onScroll = throttle(() => updatePosition(), 100);
window.addEventListener("scroll", onScroll);
onScroll.cancel(); // 쿨다운 초기화
// 비용이 큰 계산 캐싱
const fib = memoize((n: number): number => n <= 1 ? n : fib(n - 1) + fib(n - 2));
fib(40); // 계산 실행
fib(40); // 캐시에서 즉시 반환
fib.clear(); // 캐시 초기화
// 초기화 코드를 딱 한 번만 실행
const initDB = once(() => connectDatabase());
await initDB(); // 실행
await initDB(); // 동일한 Promise 반환, 재연결 없음
initDB.reset(); // 상태 초기화
// 데이터 변환 파이프라인
const result = pipe(
rawUsers,
users => unique(users, u => u.id), // 중복 제거
users => users.filter(u => u.active), // 필터
users => groupBy(users, u => u.role), // 역할별 그룹핑
);
// 커스텀 키 함수로 객체 인자 처리
const getUser = memoize(
(user: { id: number }) => fetchUser(user.id),
(user) => String(user.id) // 참조가 달라도 id가 같으면 캐시 히트
);
// curry — 부분 적용으로 재사용 가능한 함수 생성
const add = curry((a: number, b: number) => a + b);
const add10 = add(10);
add10(5); // 15
add10(20); // 30
const clampRange = curry(
(min: number, max: number, v: number) => Math.min(Math.max(v, min), max)
);
const clamp0to100 = clampRange(0)(100);
clamp0to100(150); // 100
// curry + pipe 조합 — 선언적 데이터 변환
const multiply = curry((factor: number, n: number) => n * factor) as
(factor: number) => (n: number) => number;
const double = multiply(2);
const triple = multiply(3);
pipe(5, double, triple); // 30 (5 → 10 → 30)
// negate — 술어 함수의 결과를 반전
const isEven = (n: number) => n % 2 === 0;
[1, 2, 3, 4, 5].filter(negate(isEven)); // [1, 3, 5]
const isNil = (v: unknown): v is null | undefined => v == null;
[1, null, 2, undefined, 3].filter(negate(isNil)); // [1, 2, 3]
// pipe + negate 조합
const processItems = (items: string[]) =>
pipe(items,
xs => xs.filter(negate(s => s.length === 0)), // 빈 문자열 제거
xs => unique(xs), // 중복 제거
);
// tap — pipe/compose 체인에서 부수 효과만 실행, 값은 그대로 통과
const processOrder = pipe(
order,
tap(o => logger.info("order received", { id: o.id })),
validateOrder,
tap(o => analytics.track("order.validated", { id: o.id })),
chargePayment,
tap(o => sendConfirmation(o)),
);
// 디버깅용 단독 사용
[1, 2, 3].map(tap(console.log)); // 각 값을 출력하면서 배열은 그대로 반환
// pipe 중간 관찰
const result = pipe(
rawUsers,
tap(xs => console.log("원본:", xs.length)),
xs => xs.filter(u => u.active),
tap(xs => console.log("활성 유저:", xs.length)),
);
// pipeAsync — 동기·비동기 함수 혼합 파이프라인
const user = await pipeAsync(
rawInput,
sanitize, // 동기
validateCredentials, // async: DB 조회
tap(u => logger.info("login", { id: u.id })), // 동기 side-effect
enrichWithPermissions, // async: 권한 조회
);
// pipe vs pipeAsync: 비동기 단계가 하나라도 있으면 pipeAsync 사용
const report = await pipeAsync(
startDate,
fetchRawData, // async
normalize, // 동기
aggregateByDay, // 동기
generatePDF, // async
);
BehaviorSubject(임의 값 설정 가능)와의 차이: createStateMachine은 오직 정의된 전이만 허용해 불법 상태 전이를 컴파일 타임·런타임 모두 차단한다.
| 메서드 / 프로퍼티 |
설명 |
.state |
현재 상태 (읽기 전용) |
.send(event) |
이벤트 전송. 전이 성공이면 true, 현재 상태에서 미정의면 false (throw 없음) |
.can(event) |
현재 상태에서 이벤트 허용 여부 확인 — UI 버튼 비활성화에 유용 |
.subscribe(handler) |
상태 변경 구독. 즉시 현재 상태 전달. 반환값은 구독 해제 함수 |
.reset() |
초기 상태로 복원, 구독자에게 알림 |
import { createStateMachine } from "simple-ts-tools";
// 비동기 데이터 로딩 상태
const loader = createStateMachine({
initial: "idle",
transitions: {
idle: { FETCH: "loading" },
loading: { RESOLVE: "success", REJECT: "error" },
success: { RESET: "idle" },
error: { RETRY: "loading", RESET: "idle" },
},
});
loader.state; // "idle"
loader.send("FETCH"); // true → "loading"
loader.send("RESET"); // false → loading에서 RESET 미정의, 무시됨
loader.send("RESOLVE"); // true → "success"
// UI 버튼 활성화 여부
loader.can("FETCH"); // false (현재 success 상태)
loader.can("RESET"); // true
// 상태 구독 — React 컴포넌트나 UI 업데이트
const unsub = loader.subscribe((state, event) => {
console.log(`→ ${state} (via ${event ?? "init"})`);
});
// 즉시: "→ success (via init)"
loader.send("RESET");
// "→ idle (via RESET)"
unsub(); // 구독 해제
// 다단계 폼 스텝
const form = createStateMachine({
initial: "step1",
transitions: {
step1: { NEXT: "step2" },
step2: { NEXT: "step3", BACK: "step1" },
step3: { BACK: "step2", SUBMIT: "done" },
done: {},
},
});
form.send("NEXT"); form.send("NEXT"); // step3
form.can("SUBMIT"); // true
form.can("NEXT"); // false — step3에서 NEXT 미정의
// 미디어 플레이어
const player = createStateMachine({
initial: "stopped",
transitions: {
stopped: { PLAY: "playing" },
playing: { PAUSE: "paused", STOP: "stopped" },
paused: { PLAY: "playing", STOP: "stopped" },
},
});
import { scan } from "simple-ts-tools";
// 누적 합 — running totals
scan([1, 2, 3, 4], 0, (acc, x) => acc + x);
// [1, 3, 6, 10]
// 잔액 추적 — 입출금 내역
const transactions = [100, -10, 20, -5, 50];
scan(transactions, 0, (acc, x) => acc + x);
// [100, 90, 110, 105, 155]
// 진행률 — 작업 완료 비율 누적
const weights = [10, 30, 60]; // 총합 100
scan(weights, 0, (acc, w) => acc + w);
// [10, 40, 100]
// 프리픽스 합 배열 생성 — O(1) 범위 합 조회
const arr = [3, 1, 4, 1, 5, 9];
const prefix = [0, ...scan(arr, 0, (acc, x) => acc + x)];
// prefix: [0, 3, 4, 8, 9, 14, 23]
const rangeSum = (l: number, r: number) => prefix[r + 1] - prefix[l];
rangeSum(2, 4); // arr[2]+arr[3]+arr[4] = 4+1+5 = 10
| 함수/클래스 |
설명 |
createHttpClient |
baseUrl·기본 헤더·인터셉터·타임아웃을 공유하는 HTTP 클라이언트 인스턴스 생성 |
RequestBuilder |
컴파일 타임 안전성이 보장된 HTTP 요청 빌더 (URL·메서드 지정 전까지 send() 불가) |
HttpClientOptions
| 옵션 |
타입 |
기본값 |
설명 |
baseUrl |
string |
"" |
모든 요청의 기본 URL. 절대 URL은 이 값을 무시한다. |
headers |
Record<string, string> |
{} |
모든 요청에 기본으로 포함될 헤더 |
timeout |
number |
— |
기본 타임아웃 (ms). 개별 요청에서 override 가능 |
HttpClient 메서드
| 메서드 |
설명 |
get<T>(url, options?) |
GET 요청 |
post<T>(url, body?, options?) |
POST 요청 |
put<T>(url, body?, options?) |
PUT 요청 |
patch<T>(url, body?, options?) |
PATCH 요청 |
delete<T>(url, options?) |
DELETE 요청 |
request<T>(config) |
범용 요청 (인터셉터 체인 포함) |
interceptors.request.use(fn) |
요청 인터셉터 등록 → ID 반환 |
interceptors.response.use(fn, onError?) |
응답 인터셉터 등록 |
interceptors.request.eject(id) |
인터셉터 제거 |
import { createHttpClient, HttpError, TimeoutError } from "simple-ts-tools";
// 클라이언트 생성 — 공통 설정 한 곳에서 관리
const client = createHttpClient({
baseUrl: "https://api.example.com",
timeout: 10_000,
headers: { Authorization: `Bearer ${getToken()}` },
});
// 타입 안전 요청
const user = await client.get<User>("/users/123");
const post = await client.post<Post>("/posts", { title: "Hello", body: "..." });
await client.delete("/posts/42");
// 쿼리 파라미터
const list = await client.get<User[]>("/users", {
params: { page: 2, limit: 20, active: true },
});
// 요청 인터셉터 — 모든 요청에 trace ID 추가
const id = client.interceptors.request.use((config) => ({
...config,
headers: { ...config.headers, "X-Request-ID": crypto.randomUUID() },
}));
client.interceptors.request.eject(id); // 인터셉터 제거
// 응답 인터셉터 — 401 시 토큰 갱신 후 재시도
client.interceptors.response.use(null, async (error) => {
if (error instanceof HttpError && error.status === 401) {
await refreshAccessToken();
return client.request(error.config); // 원래 요청 재시도
}
throw error;
});
// 에러 처리 — HttpError는 status·body·config를 포함
try {
await client.post("/validate", formData);
} catch (e) {
if (e instanceof HttpError) {
console.error(`${e.status}: ${JSON.stringify(e.body)}`);
} else if (e instanceof TimeoutError) {
console.error(`타임아웃 (${e.timeoutMs}ms)`);
}
}
URL과 메서드가 모두 지정된 경우에만 .build() / .send<T>() 호출 가능 (타입 레벨 강제).
import { RequestBuilder } from "simple-ts-tools";
const request = new RequestBuilder()
.url("https://api.example.com/users")
.method("GET")
.param("page", "1")
.build();
const data = await new RequestBuilder()
.url("https://api.example.com/users")
.method("POST")
.body({ name: "Alice" })
.send<{ id: number }>();
| 함수 |
시그니처 |
설명 |
isAlphabet |
isAlphabet(char: string): boolean |
단일 문자가 알파벳인지 확인 |
isAlphanumeric |
isAlphanumeric(str: string): boolean |
문자열이 영문자·숫자로만 구성됐는지 확인 |
isArray |
isArray(value: unknown): value is unknown[] |
배열 여부 확인 |
isBoolean |
isBoolean(value: unknown): value is boolean |
boolean 여부 확인 |
isDefined |
isDefined<T>(value: T): value is NonNullable<T> |
null/undefined가 아닌지 확인 — 타입을 NonNullable로 좁힘 |
isEmail |
isEmail(value: string): boolean |
이메일 형식 검증 |
isFunction |
isFunction(value: unknown): value is Function |
함수 여부 확인 |
isNil |
isNil(value: unknown): value is null | undefined |
null 또는 undefined 여부 확인 |
isNumber |
isNumber(value: unknown): value is number |
number 여부 확인 (NaN은 false) |
isObject |
isObject(value: unknown): value is Record<string, unknown> |
plain 객체 여부 (null·Array·Date 등은 false) |
isString |
isString(value: unknown): value is string |
string 여부 확인 |
isUrl |
isUrl(value: string, allowedProtocols?: string[]): boolean |
URL 형식 검증 (기본: http/https만 허용) |
import { isEmail, isUrl } from "simple-ts-tools";
// 폼 검증
isEmail("user@example.com"); // true
isEmail("user+tag@co.kr"); // true
isEmail("user@"); // false
isEmail("@example.com"); // false
isUrl("https://example.com"); // true
isUrl("http://localhost:3000"); // true
isUrl("ftp://files.example.com"); // false (기본은 http/https만)
isUrl("ftp://files.example.com", ["ftp:"]); // true
// XSS 방지 — javascript: URL 차단
isUrl("javascript:alert(1)"); // false
// 타입 가드 — 런타임 타입 좁히기
isNil(null); // true
isNil(0); // false
isDefined("hello"); // true
// filter와 조합 시 반환 타입이 자동으로 좁혀짐
const values: (string | null | undefined)[] = ["a", null, "b", undefined];
const strings = values.filter(isDefined); // string[]
// 런타임 타입 분기
function process(value: unknown) {
if (isString(value)) return value.toUpperCase(); // string으로 좁혀짐
if (isNumber(value)) return value.toFixed(2); // number로 좁혀짐
if (isArray(value)) return value.length;
if (isObject(value)) return Object.keys(value).length;
}
isNumber(NaN); // false — NaN 방어
isObject([]); // false — Array는 plain 객체가 아님
isObject(new Date()); // false — 인스턴스는 제외
| 함수 |
시그니처 |
설명 |
clamp |
clamp(value: number, min: number, max: number): number |
값을 [min, max] 범위로 제한 |
formatNumber |
formatNumber(value: number, options?): string |
천단위 구분·소수점·통화·compact 포맷 |
lerp |
lerp(start: number, end: number, t: number): number |
두 값 사이의 선형 보간 (t=0→start, t=1→end) |
normalize |
normalize(value: number, min: number, max: number, clamp?: boolean): number |
[min, max] → [0, 1] 정규화 |
mapRange |
mapRange(value, inMin, inMax, outMin, outMax, clamp?): number |
임의 범위 간 선형 매핑. normalize + lerp의 합성. 역방향·clamp 지원 |
percentage |
percentage(value: number, total: number, decimals?: number): number |
value가 total에서 차지하는 백분율 |
randomInt |
randomInt(min: number, max: number): number |
[min, max] 범위의 정수 난수 (양 끝 포함) |
range |
range(start: number, end: number, step?: number): number[] |
[start, end) 범위의 숫자 배열 생성 |
round |
round(value: number, decimals?: number): number |
소수 자릿수 반올림 (부동소수점 오차 보정) |
toOrdinal |
toOrdinal(n: number): string |
숫자에 영어 서수 접미사 추가 (1st, 2nd, 3rd, 11th …) |
sum |
sum(nums: number[]): number |
숫자 배열의 합 (빈 배열 → 0) |
mean |
mean(nums: number[]): number |
산술 평균 (빈 배열 → NaN) |
median |
median(nums: number[]): number |
중앙값 (빈 배열 → NaN, 원본 불변) |
mode |
mode(nums: number[]): number[] |
최빈값 배열 (공동 최빈값 모두 반환) |
variance |
variance(nums: number[], sample?: boolean): number |
분산 (기본: 모집단, sample=true: 표본) |
stddev |
stddev(nums: number[], sample?: boolean): number |
표준편차 (기본: 모집단) |
import { range, clamp } from "simple-ts-tools";
// 페이지네이션 버튼 1~5
range(1, 6); // [1, 2, 3, 4, 5]
// 짝수만, 2칸씩
range(0, 10, 2); // [0, 2, 4, 6, 8]
// 역방향
range(5, 0, -1); // [5, 4, 3, 2, 1]
// 진행률 0~100 제한
clamp(progress, 0, 100);
// 슬라이더 값 제한
clamp(inputValue, min, max);
// 숫자 포맷 — Intl.NumberFormat 기반
formatNumber(1234567); // "1,234,567"
formatNumber(1234567.89, { decimals: 2 }); // "1,234,567.89"
formatNumber(50000, { currency: "KRW" }); // "₩50,000"
formatNumber(9900000, { notation: "compact" }); // "990만" (ko-KR)
formatNumber(1234567, { locale: "en-US", decimals: 2 }); // "1,234,567.00"
// lerp — 선형 보간 (애니메이션, UI 전환)
lerp(0, 100, 0.5); // 50
lerp(10, 20, 0.25); // 12.5
lerp(0, 255, 0.8); // 204 ← 색상 채널 보간
// normalize — [min, max] → [0, 1] (스코어 정규화, 게이지 UI)
normalize(50, 0, 100); // 0.5
normalize(75, 0, 100); // 0.75
normalize(150, 0, 100); // 1.5 ← 범위 초과 허용
normalize(150, 0, 100, true); // 1 ← clamp: true로 제한
normalize(0, 0, 0); // 0 ← min === max 안전 처리
// percentage — 백분율 계산 (진행률, 통계 UI)
percentage(37, 50); // 74
percentage(1, 3, 1); // 33.3
percentage(2, 3, 2); // 66.67
percentage(10, 0); // 0 ← total=0 안전 처리
// 실사용: 업로드 진행률
const progress = percentage(uploadedBytes, totalBytes, 1);
// "74.5%"
// mapRange — 임의 두 범위 간 선형 매핑 (normalize + lerp의 합성)
// mapRange(v, inMin, inMax, outMin, outMax)
mapRange(50, 0, 100, 0, 800) // 400 — 데이터값 → 픽셀 좌표
mapRange(0.7, 0, 1, 0, 100) // 70 — 슬라이더(0~1) → 볼륨(0~100)
mapRange(100, 0, 100, 32, 212) // 212 — 섭씨 → 화씨
mapRange(0, 0, 100, 32, 212) // 32
// 역방향 범위 지원
mapRange(-30, 0, -60, 0, 100) // 50 — 오디오 dB(0~-60dB) → %(0~100%)
// clamp: true — 범위 초과 방지
mapRange(150, 0, 100, 0, 255, true) // 255 (extrapolation 없이 출력 범위로 제한)
mapRange(-50, 0, 100, 0, 255, true) // 0
// normalize + lerp와 동치
mapRange(v, a, b, c, d) // === lerp(c, d, normalize(v, a, b))
// 데이터 시각화 — 데이터 배열을 픽셀 좌표로 일괄 변환
const min = Math.min(...data), max = Math.max(...data);
const pixels = data.map(v => mapRange(v, min, max, 0, chartWidth));
// 영어 서수 — 리더보드, 순위, 날짜 표시
toOrdinal(1); // "1st"
toOrdinal(2); // "2nd"
toOrdinal(3); // "3rd"
toOrdinal(4); // "4th"
toOrdinal(11); // "11th" ← 예외
toOrdinal(21); // "21st"
toOrdinal(112); // "112th" ← 예외 (끝 두 자리 12)
// 실사용
`${toOrdinal(rank)} place`; // "1st place"
`${toOrdinal(day)} of December`; // "25th of December"
rankings.map((u, i) => `${toOrdinal(i+1)}: ${u.name}`);
// 통계 함수 — 대시보드, 성적 분석, 데이터 시각화
const scores = [70, 80, 85, 90, 95];
sum(scores); // 420
mean(scores); // 84
median(scores); // 85
mode([1, 2, 2, 3]); // [2]
mode([1, 1, 2, 2]); // [1, 2] 공동 최빈값
// 분산 & 표준편차 — 데이터 분포도 측정
const data = [2, 4, 4, 4, 5, 5, 7, 9];
variance(data); // 4 (모집단 분산, n으로 나눔)
variance(data, true); // 4.571 (표본 분산, n-1로 나눔 — Bessel 보정)
stddev(data); // 2 (모집단 표준편차)
stddev(data, true); // 2.138 (표본 표준편차)
// 실사용: 성적 편차 계산
const classStdDev = stddev(scores); // 8.60
const zScore = (score - mean(scores)) / classStdDev;
| 함수 |
시그니처 |
설명 |
deepClone |
deepClone<T>(value: T): T |
재귀적 깊은 복사 (Date/Map/Set/RegExp 포함) |
deepEqual |
deepEqual(a: unknown, b: unknown): boolean |
재귀적 깊은 동등 비교 |
deepMerge |
deepMerge<T, S>(target: T, source: S): T & S |
두 plain 객체를 재귀적으로 병합 (배열은 source로 덮어씀) |
flattenObject |
flattenObject(obj, separator?): Record<string, unknown> |
중첩 객체 → 점 구분자 평탄 키 ({ "a.b.c": 1 }) |
unflattenObject |
unflattenObject(obj, separator?): Record<string, unknown> |
평탄 키 → 중첩 객체 복원 (왕복 가능) |
getIn |
getIn(obj: unknown, path: string): unknown |
점 구분자 경로로 중첩 값 읽기 (없으면 undefined) |
setIn |
setIn<T>(obj: T, path: string, value: unknown): T |
점 구분자 경로에 값 설정한 새 객체 반환 (불변) |
hasIn |
hasIn(obj: unknown, path: string): boolean |
점 구분자 경로가 존재하는지 확인 |
fromPairs |
fromPairs<K, V>(pairs: [K, V][]): Record<K, V> |
[키, 값] 튜플 배열 → 객체 |
invert |
invert<K, V>(obj: Record<K, V>): Record<string, K> |
키와 값을 뒤집은 새 객체 반환 |
toPairs |
toPairs<T>(obj: T): [keyof T, T[keyof T]][] |
객체 → [키, 값] 튜플 배열 |
mapKeys |
mapKeys<V>(obj: Record<string, V>, keyFn: (key: string) => string): Record<string, V> |
모든 키에 변환 함수 적용 |
mapValues |
mapValues<T, U>(obj: T, valueFn: (value, key) => U): Record<string, U> |
모든 값에 변환 함수 적용 |
omit |
omit<T, K extends keyof T>(obj: T, keys: readonly K[]): Omit<T, K> |
지정한 키를 제외한 새 객체 반환 |
omitBy |
omitBy<T>(obj: T, predicate: (value, key) => boolean): Partial<T> |
predicate 통과 항목을 제외한 새 객체 반환 |
pick |
pick<T, K extends keyof T>(obj: T, keys: readonly K[]): Pick<T, K> |
지정한 키만 추출한 새 객체 반환 |
pickBy |
pickBy<T>(obj: T, predicate: (value, key) => boolean): Partial<T> |
predicate 통과 항목만 추출한 새 객체 반환 |
diff |
diff(a, b: Record<string, unknown>): DiffResult |
두 객체의 얕은 diff — 최상위 키 기준 added/removed/changed 반환 |
isDiffEmpty |
isDiffEmpty(result: DiffResult): boolean |
diff 결과에 변경사항이 없으면 true |
deepDiff |
deepDiff(a, b): Change[] |
재귀적 경로 기반 diff — 중첩 객체·배열 내 변경을 "user.age", "tags[1]" 형태의 경로로 반환 |
hasDeepDiff |
hasDeepDiff(a, b): boolean |
재귀적으로 변경 여부만 빠르게 확인 |
deepPatch |
deepPatch<T>(obj: T, changes: Change[]): T |
deepDiff() 변경 목록을 적용해 새 객체 반환 (비파괴) |
invertChanges |
invertChanges(changes: Change[]): Change[] |
변경 방향 역전 (add↔remove, update oldValue↔newValue) — undo/redo에 사용 |
defaults |
defaults<T>(target: T, ...sources: Partial<T>[]): T |
target의 undefined 속성만 source로 채움 (null·기존값 유지) |
omitNil |
omitNil<T>(obj: T): ... |
null·undefined 속성 제거 (0·false·"" 유지) |
omitFalsy |
omitFalsy<T>(obj: T): Partial<T> |
모든 falsy 속성(null·undefined·0·false·"") 제거 |
deepFreeze |
deepFreeze<T>(obj: T): DeepReadonly<T> |
객체를 재귀적으로 동결 — Object.freeze()의 깊은 버전. 순환 참조 안전 |
반환 타입이 Pick<T, K> / Omit<T, K>로 정확히 추론되어 이후 코드에서 추가 타입 단언 불필요.
import { pick, omit, pickBy, omitBy } from "simple-ts-tools";
// API 응답에서 필요한 필드만 추출
const user = { id: 1, name: "Alice", password: "secret", token: "xyz" };
pick(user, ["id", "name"]); // { id: 1, name: "Alice" }
// 민감 필드 제거 후 클라이언트 전달
omit(user, ["password", "token"]); // { id: 1, name: "Alice" }
// 값 조건으로 필터링 — null/undefined 제거
const config = { host: "localhost", port: 3000, debug: null, timeout: undefined };
pickBy(config, v => v != null); // { host: "localhost", port: 3000 }
omitBy(config, v => v == null); // 동일한 결과 (omitBy는 pickBy의 반전)
// 키 조건으로 필터링 — private 필드 제거
const dto = { _id: "abc", name: "Alice", _version: 2, age: 30 };
omitBy(dto, (_, k) => String(k).startsWith("_")); // { name: "Alice", age: 30 }
// 깊은 복사 — 불변 상태 업데이트
const next = deepClone(state);
next.user.scores.push(30); // state.user.scores는 변하지 않음
// 키 일괄 변환 — API snake_case → camelCase
mapKeys({ background_color: "#fff", font_size: 16 }, kebabToCamel);
// { backgroundColor: "#fff", fontSize: 16 }
// 값 일괄 변환
mapValues({ a: "1", b: "2" }, Number); // { a: 1, b: 2 }
mapValues({ a: 1, b: 2 }, (v, k) => `${k}=${v}`); // { a: "a=1", b: "b=2" }
// 깊은 동등 비교 — ===으로 안 되는 타입들
deepEqual({ a: [1, 2] }, { a: [1, 2] }); // true
deepEqual(new Date("2024-01-01"), new Date("2024-01-01")); // true
deepEqual(new Map([["k", 1]]), new Map([["k", 1]])); // true
deepEqual([], {}); // false
// 재귀적 병합 — plain 객체끼리만 재귀, 배열·Date 등은 source로 덮어씀
const defaults = { host: "localhost", port: 3000, db: { name: "dev", pool: 5 } };
const userConfig = { port: 8080, db: { name: "prod" } };
deepMerge(defaults, userConfig);
// { host: "localhost", port: 8080, db: { name: "prod", pool: 5 } }
// 중첩 상태 부분 업데이트
deepMerge(state, { ui: { theme: "dark" } });
// state의 다른 ui 필드는 유지, theme만 변경
// 키-값 반전 — 코드 ↔ 이름 양방향 조회
const StatusCode = { OK: 200, NOT_FOUND: 404, SERVER_ERROR: 500 };
const byCode = invert(StatusCode);
byCode[200]; // "OK"
byCode[404]; // "NOT_FOUND"
// 열거형을 반전해 디버깅 메시지 생성
const ErrorCode = { INVALID_INPUT: "E001", NOT_FOUND: "E002" };
const codeToName = invert(ErrorCode);
codeToName["E001"]; // "INVALID_INPUT"
// 객체 ↔ [키, 값] 배열 변환 — 변환 파이프라인에서 유용
const prices = { apple: 300, banana: 150, cherry: 500 };
// 300원 이상만 유지
const expensive = fromPairs(
toPairs(prices).filter(([, price]) => price >= 300) as [string, number][]
);
// { apple: 300, cherry: 500 }
// 값에 부가세 적용
const withTax = fromPairs(
toPairs(prices).map(([k, v]) => [k, Math.round(v * 1.1)]) as [string, number][]
);
// { apple: 330, banana: 165, cherry: 550 }
// 중첩 객체 평탄화 — dotenv, 설정 파일, 폼 상태 직렬화
flattenObject({ a: { b: { c: 1 }, d: 2 } });
// { "a.b.c": 1, "a.d": 2 }
flattenObject({ user: { name: "Alice", address: { city: "Seoul" } } });
// { "user.name": "Alice", "user.address.city": "Seoul" }
flattenObject({ items: ["x", "y"] });
// { "items.0": "x", "items.1": "y" }
// 커스텀 separator
flattenObject({ a: { b: 1 } }, "/");
// { "a/b": 1 }
// 복원 (왕복)
const flat = flattenObject(config);
Object.keys(flat); // ["db.host", "db.port", "db.name", ...]
unflattenObject(flat); // 원본 config 복원
// 실사용: API 응답의 중첩 에러 경로를 폼 필드에 매핑
const errors = flattenObject(apiError.details);
// { "user.email": "이메일 형식이 올바르지 않습니다", "user.name": "필수 항목입니다" }
setFieldErrors(errors);
// 점 구분자 경로 접근 — 중첩 상태, 폼, config에서 자주 사용
const state = { user: { address: { city: "Seoul" }, scores: [90, 85] } };
getIn(state, "user.address.city"); // "Seoul"
getIn(state, "user.scores.0"); // 90
getIn(state, "user.age"); // undefined (존재하지 않는 경로)
hasIn(state, "user.address.city"); // true
hasIn(state, "user.address.zip"); // false
hasIn({ a: undefined }, "a"); // true (키는 존재, 값이 undefined인 경우)
// setIn — 불변 업데이트 (원본 변경 없음)
const next = setIn(state, "user.address.city", "Busan");
state.user.address.city; // "Seoul" (변경 없음)
next.user.address.city; // "Busan"
setIn({}, "a.b.c", 1); // { a: { b: { c: 1 } } } (중간 경로 자동 생성)
// 실사용: 폼 필드 개별 업데이트
const form = setIn(currentForm, `items.${index}.quantity`, newQty);
// 얕은 객체 diff — 폼 dirty 상태, 설정 변경 감지, 패치 생성
const a = { keep: 1, remove: 2, change: "old" };
const b = { keep: 1, add: 3, change: "new" };
const result = diff(a, b);
result.added; // { add: 3 }
result.removed; // { remove: 2 }
result.changed; // { change: { from: "old", to: "new" } }
// 변경사항 없는지 빠른 확인
isDiffEmpty(diff(initial, current)); // true → dirty 없음
// 실사용: 폼 dirty state 감지
const initial = { name: "Alice", email: "alice@example.com" };
const current = { name: "Bob", email: "alice@example.com" };
const d = diff(initial, current);
if (!isDiffEmpty(d)) {
console.log("변경된 필드:", d.changed); // { name: { from: "Alice", to: "Bob" } }
}
// PATCH 페이로드 생성 — 변경된 필드만 서버로 전송
const patch = diff(original, edited).changed;
api.patch("/user", Object.fromEntries(Object.entries(patch).map(([k, v]) => [k, v.to])));
import { deepDiff, deepPatch, hasDeepDiff, invertChanges } from "simple-ts-tools";
// 재귀적 경로 기반 diff — 중첩 객체·배열까지 추적
const changes = deepDiff(
{ user: { name: "Alice", age: 30 }, tags: ["ts"] },
{ user: { name: "Alice", age: 31 }, tags: ["ts", "js"] },
);
// [
// { type: "update", path: "user.age", oldValue: 30, newValue: 31 },
// { type: "add", path: "tags[1]", newValue: "js" },
// ]
// 폼 dirty 감지 — 중첩 필드까지 정확하게
const saved = { profile: { name: "Alice", bio: "Engineer" } };
const current = { profile: { name: "Alice", bio: "Senior Engineer" } };
hasDeepDiff(saved, current); // true
deepDiff(saved, current); // [{ type: "update", path: "profile.bio", ... }]
// 변경 적용 — 비파괴 (원본 불변)
const updated = deepPatch(original, changes);
// undo/redo — 변경 방향 역전
const undoChanges = invertChanges(changes);
const restored = deepPatch(updated, undoChanges); // original과 동일
// 다단계 undo 히스토리
let state = { count: 0 };
const history: ReturnType<typeof deepDiff>[] = [];
function applyUpdate(next: typeof state) {
history.push(deepDiff(state, next));
state = deepPatch(state, history.at(-1)!);
}
applyUpdate({ count: 1 });
applyUpdate({ count: 2 });
// undo
state = deepPatch(state, invertChanges(history.pop()!)); // count: 1
// 감사 로그 — 정확한 변경 경로와 이전/이후 값 기록
const auditLog = deepDiff(before, after);
await db.insert("audit_log", { userId, changes: JSON.stringify(auditLog) });
// defaults — undefined인 속성만 채움 (deepMerge와의 차이)
// deepMerge: source가 target을 덮어씀 (source 우선)
// defaults: target의 기존 값 유지, undefined만 source에서 채움 (target 우선)
defaults({ a: 1, b: undefined }, { a: 99, b: 2, c: 3 })
// { a: 1, b: 2, c: 3 } — a는 target 유지, b는 source에서 채움
defaults({ a: null }, { a: 42 })
// { a: null } — null은 "명시적으로 없음"이므로 유지
// 함수 옵션 기본값 처리
const opts = defaults(userOptions, { timeout: 5000, retries: 3, verbose: false });
// 여러 소스 — 왼쪽부터 적용 (먼저 나온 소스가 우선)
defaults({ a: undefined }, { a: 1, b: 2 }, { a: 9, c: 3 })
// { a: 1, b: 2, c: 3 } — a는 첫 번째 소스의 1
// omitNil — null·undefined 제거 (0·false·"" 유지)
omitNil({ a: 1, b: null, c: undefined, d: 0, e: "" })
// { a: 1, d: 0, e: "" }
// API 파라미터 정리 — 선택하지 않은 필터는 쿼리에서 자동 제외
const params = omitNil({
page: 1,
search: searchQuery || null, // 빈 검색어
category: selectedCategory, // 미선택이면 undefined
sort: "createdAt",
});
fetch(`/api/items?${buildQueryString(params)}`);
// omitFalsy — 더 공격적: 0·false·""도 제거
omitFalsy({ a: 1, b: null, c: 0, d: false, e: "", f: "hello" })
// { a: 1, f: "hello" }
// omitNil vs omitFalsy 선택 기준:
// omitNil: 0·false·""가 의미 있는 값일 때 (수량, 토글, 빈 문자열 초기화)
// omitFalsy: "있는 값"만 전달하면 될 때 (CSS 클래스 맵, 태그 필터 등)
// deepFreeze — Object.freeze()의 깊은 버전 (런타임 + 컴파일 타임 불변성)
const config = deepFreeze({
api: { url: "https://api.example.com", timeout: 5_000 },
features: { darkMode: true },
});
config.api.timeout = 0; // TypeError: Cannot assign to read only property
// TypeScript: config.api.timeout → readonly number
// 테스트 픽스처 — 테스트 간 데이터 오염 방지
const FIXTURE = deepFreeze({ id: 1, roles: ["admin", "user"] });
FIXTURE.roles.push("hacker"); // TypeError — 테스트 격리 보장
// DeepReadonly<T> 유틸리티 타입 단독 사용
type Config = DeepReadonly<{ db: { host: string; port: number } }>;
// → { readonly db: { readonly host: string; readonly port: number } }
타입 안전한 에러 처리 패턴. 함수가 throw 대신 Result를 반환하면 호출자가 에러 케이스를 반드시 처리해야 한다 (컴파일러가 강제).
| API |
설명 |
ok<T>(value) |
Ok<T> 생성 |
err<E>(error) |
Err<E> 생성 |
tryCatch(fn) |
동기 함수 실행 → Result<T, unknown> |
tryCatchAsync(fn) |
비동기 함수 실행 → Promise<Result<T, unknown>> |
mapResult(result, fn) |
Ok이면 값 변환, Err이면 그대로 전파 |
unwrapOr(result, fallback) |
Ok면 value, Err면 fallback 반환 |
import { ok, err, tryCatch, tryCatchAsync, mapResult, unwrapOr } from "simple-ts-tools";
// 반환 타입으로 에러 가능성을 명시
function divide(a: number, b: number): Result<number, string> {
if (b === 0) return err("division by zero");
return ok(a / b);
}
const result = divide(10, 2);
if (result.ok) {
console.log(result.value); // 5 — 타입: number
} else {
console.error(result.error); // 타입: string
}
// JSON.parse 같은 throw 가능 함수를 안전하게 감싸기
const parsed = tryCatch(() => JSON.parse(rawInput));
const value = unwrapOr(parsed, {});
// 비동기 API 호출
const data = await tryCatchAsync(() =>
new RequestBuilder().url("/api/users").method("GET").send<User[]>()
);
const users = mapResult(data, us => us.filter(u => u.active));
네이티브 Set을 위한 집합 연산 유틸리티. 배열 변환 없이 O(n) 순회와 O(1) 조회를 유지하며, 모든 함수는 입력 Set을 수정하지 않는 불변(non-destructive) 함수다.
| 함수 |
시그니처 |
설명 |
setUnion |
setUnion<T>(a, b): Set<T> |
합집합 — a ∪ b |
setIntersection |
setIntersection<T>(a, b): Set<T> |
교집합 — a ∩ b |
setDifference |
setDifference<T>(a, b): Set<T> |
차집합 — a에만 있는 원소 (a − b) |
setSymmetricDifference |
setSymmetricDifference<T>(a, b): Set<T> |
대칭 차집합 — 한 쪽에만 있는 원소 |
isSubset |
isSubset<T>(a, b): boolean |
a ⊆ b — a의 모든 원소가 b에 존재 |
isSuperset |
isSuperset<T>(a, b): boolean |
a ⊇ b — b의 모든 원소가 a에 존재 |
isDisjoint |
isDisjoint<T>(a, b): boolean |
서로소 — 공통 원소 없음 |
setEquals |
setEquals<T>(a, b): boolean |
두 Set이 동일한지 (순서 무관) |
setUnionAll |
setUnionAll<T>(sets): Set<T> |
여러 Set의 합집합 |
setIntersectionAll |
setIntersectionAll<T>(sets): Set<T> |
여러 Set의 교집합 |
import { setUnion, setIntersection, setDifference, isSubset, isDisjoint } from "simple-ts-tools";
const a = new Set([1, 2, 3]);
const b = new Set([2, 3, 4]);
setUnion(a, b) // Set { 1, 2, 3, 4 }
setIntersection(a, b) // Set { 2, 3 }
setDifference(a, b) // Set { 1 } (a에만 있음)
setDifference(b, a) // Set { 4 } (b에만 있음)
setSymmetricDifference(a, b) // Set { 1, 4 } (한 쪽에만 있음)
isSubset(new Set([1, 2]), a) // true
isSuperset(a, new Set([1, 2])) // true
isDisjoint(new Set([1, 2]), new Set([3, 4])) // true
setEquals(new Set([1, 2, 3]), new Set([3, 1, 2])) // true (순서 무관)
// 여러 Set 한 번에 처리
setUnionAll([new Set([1,2]), new Set([2,3]), new Set([3,4])])
// Set { 1, 2, 3, 4 }
setIntersectionAll([new Set([1,2,3]), new Set([2,3,4]), new Set([3,4,5])])
// Set { 3 }
// ─── 실사용 시나리오
// 권한 검사 — 요청 권한이 부여된 권한에 모두 포함되는지
const required = new Set(["read", "write"]);
const granted = new Set(["read", "write", "delete"]);
isSubset(required, granted) // true → 허용
// 태그 필터링 — 선택 태그가 하나라도 포함된 포스트
const filter = new Set(["react"]);
posts.filter(p => !isDisjoint(p.tags, filter))
// 변경 감지 — before/after 비교로 추가/삭제 항목 추출
const added = setDifference(after, before); // 새로 추가된
const removed = setDifference(before, after); // 제거된
JSON 직렬화·TTL·네임스페이스를 지원하는 타입 안전 localStorage/sessionStorage 래퍼.
SSR/Node 환경에서 storage 접근 불가 시 no-op으로 동작한다 (throw 없음).
| API |
설명 |
createStorage(options?) |
TypedStorage 인스턴스 생성 |
store.set(key, value, options?) |
JSON 직렬화 저장. ttl (ms) 옵션으로 만료 설정 |
store.get<T>(key) |
역직렬화 반환. 없거나 만료됐으면 null |
store.has(key) |
유효한 항목이 존재하면 true |
store.remove(key) |
항목 삭제 |
store.clear() |
prefix 항목 전체 삭제 (prefix 없으면 전체 초기화) |
store.keys() |
현재 prefix의 모든 raw key 반환 |
StorageOptions
| 옵션 |
타입 |
기본값 |
설명 |
type |
"local" | "session" |
"local" |
localStorage 또는 sessionStorage |
prefix |
string |
— |
모든 키에 자동으로 붙는 네임스페이스 ("prefix:key") |
import { createStorage } from "simple-ts-tools";
// 기본 — localStorage, 네임스페이스 없음
const store = createStorage();
store.set("theme", "dark");
store.get<string>("theme"); // "dark"
// TTL — 1시간 후 자동 만료
store.set("authToken", token, { ttl: 60 * 60 * 1000 });
// 네임스페이스 — 앱별 키 충돌 방지
const appStore = createStorage({ prefix: "myapp" });
appStore.set("user", { id: 1, name: "Alice" });
// localStorage에는 "myapp:user"로 저장됨
// 타입 안전 get
const user = appStore.get<{ id: number; name: string }>("user");
user?.name; // "Alice"
// sessionStorage — 탭/창 닫으면 자동 삭제
const sessionStore = createStorage({ type: "session", prefix: "cart" });
sessionStore.set("items", cartItems);
// has / remove
appStore.has("user"); // true
appStore.remove("user");
appStore.has("user"); // false
// 현재 prefix의 모든 키
appStore.keys(); // ["user", "theme", ...]
// prefix 범위 내 전체 삭제
appStore.clear(); // "myapp:*" 항목만 삭제, 다른 앱 키 유지
| 함수 |
시그니처 |
설명 |
formatPhoneNumber |
formatPhoneNumber(value: string): string |
한국 전화번호를 하이픈 포맷으로 변환 |
formatPhoneNumber("01012345678"); // "010-1234-5678"
formatPhoneNumber("0212345678"); // "02-123-4567" (8자리 지역번호 형식)
| 함수 |
시그니처 |
설명 |
camelToKebab |
camelToKebab(str: string): string |
camelCase/PascalCase → kebab-case (약어 처리 포함) |
camelToSnake |
camelToSnake(str: string): string |
camelCase/PascalCase → snake_case (약어 처리 포함) |
capitalize |
capitalize(str: string): string |
첫 글자 대문자, 나머지 소문자 |
snakeToCamel |
snakeToCamel(str: string): string |
snake_case → camelCase |
escapeHtml |
escapeHtml(str: string): string |
HTML 특수 문자를 엔티티로 변환 (& < > " ') |
formatBytes |
formatBytes(bytes: number, decimals?: number): string |
바이트 수를 사람이 읽기 좋은 단위로 변환 (B/KB/MB/GB/TB) |
template |
template(str: string, data: Record<string, ...>): string |
{{변수명}} 자리 표시자를 데이터로 치환 |
unescapeHtml |
unescapeHtml(str: string): string |
HTML 엔티티를 원래 문자로 복원 |
isEmpty |
isEmpty(value: string | null | undefined): boolean |
빈 문자열·공백·null·undefined이면 true |
kebabToCamel |
kebabToCamel(str: string): string |
kebab-case → camelCase |
truncate |
truncate(str: string, maxLength: number, suffix?: string): string |
maxLength 초과 시 suffix(기본 "…")를 붙여 잘라냄 |
slugify |
slugify(str: string, options?: SlugifyOptions): string |
문자열을 URL-safe 슬러그로 변환 (악센트 제거, 특수 문자 제거) |
mask |
mask(str: string, start?: number, end?: number, char?: string): string |
지정 범위를 마스킹 문자로 대체 |
maskEmail |
maskEmail(email: string): string |
이메일 local 파트 앞 2자 이후 마스킹 |
maskCard |
maskCard(cardNumber: string): string |
카드번호 마지막 4자리만 표시, 4자리씩 하이픈 구분 |
maskPhone |
maskPhone(phone: string): string |
전화번호 중간 자리 마스킹 (한국 형식) |
wordCount |
wordCount(str: string): number |
단어 수 반환 (공백 기준, 연속 공백 정규화) |
words |
words(str: string): string[] |
문자열을 단어 배열로 분리 |
truncateWords |
truncateWords(str: string, maxWords: number, suffix?: string): string |
단어 수 기준으로 잘라냄 (기본 suffix: "…") |
pluralize |
pluralize(count, singular, plural?, options?): string |
영어 복수형 자동 처리 (불규칙형은 plural로 지정) |
autoPlural |
autoPlural(word: string): string |
영어 복수형 규칙 적용 (s/es/ies) — pluralize의 내부 규칙 직접 사용 시 |
toCamelCase |
toCamelCase(str: string): string |
어떤 형식이든 camelCase로 변환 (공백·하이픈·언더스코어·PascalCase·약어 모두 처리) |
toPascalCase |
toPascalCase(str: string): string |
어떤 형식이든 PascalCase로 변환 |
toTitleCase |
toTitleCase(str: string): string |
어떤 형식이든 Title Case로 변환 (각 단어 첫 글자 대문자, 공백 구분) |
toScreamingSnake |
toScreamingSnake(str: string): string |
어떤 형식이든 SCREAMING_SNAKE_CASE로 변환 |
levenshteinDistance |
levenshteinDistance(a, b, options?): number |
레벤슈타인 편집 거리 (삽입·삭제·교체 최솟값). O(n·m) 시간, O(min(n,m)) 공간 |
similarity |
similarity(a, b, options?): number |
0~1 정규화 유사도 점수 (편집 거리 ÷ max 길이) |
fuzzyMatch |
fuzzyMatch(text, pattern, options?): boolean |
패턴의 모든 문자가 텍스트에 순서대로 존재하는지 (subsequence 포함 여부 — VS Code 스타일) |
fuzzySearch |
fuzzySearch<T>(items, query, options?): FuzzyResult<T>[] |
fuzzyMatch 필터 + similarity 점수 정렬. 자동완성·커맨드 팔레트·검색 UI에 바로 사용 가능 |
import { isEmpty, truncate, capitalize } from "simple-ts-tools";
// 폼 유효성 검사
isEmpty(""); // true
isEmpty(" "); // true
isEmpty(null); // true
isEmpty("hello"); // false
// 카드/리스트 제목 표시
truncate("긴 제목이 넘칩니다", 8); // "긴 제목이 넘…"
truncate("Hello, World!", 8, "..."); // "Hello..."
// 표시용 문자열 정규화
capitalize("hELLO wORLD"); // "Hello world"
// CSS 클래스명 / API 필드명 변환
camelToKebab("backgroundColor"); // "background-color"
camelToKebab("XMLParser"); // "xml-parser" ← 약어 처리
camelToKebab("getHTTPSResponse"); // "get-https-response"
kebabToCamel("background-color"); // "backgroundColor"
kebabToCamel(camelToKebab("myProp")); // "myProp" (왕복 가능)
// snake_case 변환 (DB 컬럼명 ↔ JS 프로퍼티 변환)
camelToSnake("backgroundColor"); // "background_color"
camelToSnake("XMLParser"); // "xml_parser" ← 약어 처리
camelToSnake("getHTTPSResponse"); // "get_https_response"
snakeToCamel("background_color"); // "backgroundColor"
snakeToCamel(camelToSnake("myProp")); // "myProp" (왕복 가능)
// 범용 케이스 변환 — 입력 형식 무관 (공백·하이픈·언더스코어·camel·Pascal 모두 처리)
// 기존 함수(kebabToCamel, snakeToCamel)는 특정 형식만 처리하지만,
// 이 함수들은 어떤 형식이든 받아서 변환한다.
toCamelCase("hello world") // "helloWorld"
toCamelCase("hello-world") // "helloWorld"
toCamelCase("hello_world") // "helloWorld"
toCamelCase("HelloWorld") // "helloWorld"
toCamelCase("XMLParser") // "xmlParser" ← 약어 처리
toPascalCase("hello world") // "HelloWorld"
toPascalCase("hello-world") // "HelloWorld"
toPascalCase("hello_world") // "HelloWorld"
toPascalCase("my-button") // "MyButton" ← 컴포넌트명 생성
toTitleCase("hello world") // "Hello World"
toTitleCase("hello-world") // "Hello World"
toTitleCase("helloWorld") // "Hello World"
toTitleCase("USER_ROLE_ADMIN") // "User Role Admin" ← 상수 → 표시 레이블
toScreamingSnake("hello world") // "HELLO_WORLD"
toScreamingSnake("helloWorld") // "HELLO_WORLD"
toScreamingSnake("maxRetryCount") // "MAX_RETRY_COUNT"
// 실사용: API 응답 필드명 → JS 프로퍼티명 일괄 변환
const apiFields = ["user_id", "first_name", "created_at"];
apiFields.map(toCamelCase); // ["userId", "firstName", "createdAt"]
// 동일한 의미의 다른 형식을 하나로 정규화
["user name", "user-name", "user_name", "UserName"].map(toCamelCase);
// 모두 "userName" — 입력 형식 무관 멱등성
// 파일 크기를 사람이 읽기 좋은 단위로 표시
formatBytes(0); // "0 B"
formatBytes(1024); // "1 KB"
formatBytes(1536); // "1.5 KB"
formatBytes(1048576); // "1 MB"
formatBytes(1234567, 1); // "1.2 MB"
formatBytes(1024 ** 3); // "1 GB"
// 업로드 UI, 파일 탐색기에서 자주 사용
`파일 크기: ${formatBytes(file.size)}` // "파일 크기: 2.34 MB"
// XSS 방지 — innerHTML에 동적 데이터 삽입 전 필수
escapeHtml('<script>alert("xss")</script>');
// '<script>alert("xss")</script>'
escapeHtml('안녕 & "반가워" <br>');
// '안녕 & "반가워" <br>'
// 역변환
unescapeHtml('<b>hello</b>'); // '<b>hello</b>'
// 왕복 무손실
const raw = '<b>Alice & "Bob"</b>';
unescapeHtml(escapeHtml(raw)) === raw; // true
// 문자열 템플릿 — {{변수명}} 치환
template("안녕하세요, {{name}}님!", { name: "Alice" });
// "안녕하세요, Alice님!"
template("{{sender}}님이 {{count}}개의 메시지를 보냈습니다.", { sender: "Bob", count: 3 });
// "Bob님이 3개의 메시지를 보냈습니다."
template("{{year}}년 {{month}}월 {{day}}일", { year: 2024, month: 6, day: 7 });
// "2024년 6월 7일"
// 정의되지 않은 변수는 빈 문자열
template("Hello, {{name}}{{title}}!", { name: "Alice" });
// "Hello, Alice!"
// URL 슬러그 생성 — 블로그 포스트, 상품 URL, SEO 경로 생성
slugify("Hello World"); // "hello-world"
slugify("café au lait"); // "cafe-au-lait" ← 악센트 제거
slugify("résumé"); // "resume"
slugify("Chapter 2: Introduction!"); // "chapter-2-introduction"
slugify("hello world---test"); // "hello-world-test" ← 연속 구분자 통합
slugify("Hello World", { separator: "_" }); // "hello_world"
slugify("Hello World", { lowercase: false }); // "Hello-World"
// 실사용: 게시물 URL 생성
const post = { title: "나의 첫 번째 포스트!" };
const url = `/posts/${slugify(post.title)}`; // "/posts/" (한글 → 비라틴 제거)
// 한글 포스트는 별도 인코딩 또는 영문 slug 필드 활용 권장
// 마스킹 — 민감 정보 표시
mask("1234567890", 0, 6); // "******7890"
mask("ABCDEFGH", 2, 6); // "AB****GH"
maskEmail("alice@example.com"); // "al***@example.com"
maskEmail("ab@test.com"); // "ab@test.com" (2자 이하 그대로)
maskCard("1234567890123456"); // "****-****-****-3456"
maskCard("1234-5678-9012-3456"); // "****-****-****-3456"
maskPhone("01012345678"); // "010-****-5678"
maskPhone("010-1234-5678"); // "010-****-5678"
maskPhone("0212345678"); // "02-****-5678"
// 단어 수 처리 — 리치 텍스트 에디터, 폼 유효성 검사
wordCount("Hello World"); // 2
wordCount(" Hello World "); // 2 (연속 공백 정규화)
wordCount(""); // 0
words("React TypeScript Next.js"); // ["React", "TypeScript", "Next.js"]
// truncateWords — 문자 수가 아닌 단어 수 기준으로 잘라냄
// (truncate는 단어 중간에서 잘릴 수 있는 문제를 이 함수로 해결)
truncateWords("Hello World Foo Bar Baz", 3); // "Hello World Foo…"
truncateWords("React TypeScript Next.js", 10); // "React TypeScript Next.js" (초과 없음)
truncateWords("Hello World Foo", 2, " [더 보기]"); // "Hello World [더 보기]"
// 실사용: 블로그 카드 미리보기
const preview = truncateWords(article.body, 20);
// 최대 20단어로 미리보기, 단어 중간에서 잘리지 않음
// 실사용: 트위터 스타일 글자 수 제한 표시
const remaining = 280 - wordCount(input) * 5; // 대략적 계산용
// pluralize — 카운트에 따른 영어 단수/복수형 처리
pluralize(1, "result") // "1 result"
pluralize(2, "result") // "2 results"
pluralize(0, "item") // "0 items"
// 자동 복수형 규칙
// +s: file→files, user→users, error→errors
// +es: bus→buses, box→boxes, watch→watches, dish→dishes
// +ies: city→cities, baby→babies, query→queries (자음+y)
// +s: boy→boys, day→days (모음+y — 변형 없음)
// 불규칙형은 plural 인자로 지정
pluralize(1, "person", "people") // "1 person"
pluralize(2, "person", "people") // "2 people"
pluralize(2, "child", "children") // "2 children"
pluralize(2, "leaf", "leaves") // "2 leaves"
// showCount: false — 단어만 반환 (템플릿에서 숫자를 직접 관리할 때)
pluralize(5, "file", undefined, { showCount: false }) // "files"
// 실사용: UI 상태 메시지
`${pluralize(selectedCount, "item")} selected`
// "1 item selected" / "3 items selected"
`Found ${pluralize(results.length, "result")}`
// "Found 1 result" / "Found 42 results"
// 실사용: 알림 뱃지
const badge = pluralize(unread, "notification", undefined, { showCount: false });
document.title = unread > 0 ? `(${unread}) ${badge} — MyApp` : "MyApp";
// "(3) notifications — MyApp" / "(1) notification — MyApp"
// ─── 퍼지 검색
// levenshteinDistance — 편집 거리 (오타 감지, "did you mean?" 기능)
levenshteinDistance("kitten", "sitting") // 3
levenshteinDistance("acess", "access") // 1
levenshteinDistance("Hello", "hello", { caseSensitive: false }) // 0
// similarity — 0~1 유사도 점수
similarity("hello", "helo") // 0.8
similarity("apple", "orange") // 0.143 (낮음 — 연관 없음)
similarity("alice", "alice") // 1
// fuzzyMatch — subsequence 포함 여부 (VS Code 파일 탐색 스타일)
fuzzyMatch("RequestBuilder.ts", "rb") // true (R...B...)
fuzzyMatch("components/Button.tsx", "btn") // true
fuzzyMatch("index.ts", "rb") // false
// fuzzySearch — 자동완성 / 커맨드 팔레트 / 검색 UI
const files = ["RequestBuilder.ts", "ResponseHandler.ts", "Button.tsx", "index.ts"];
fuzzySearch(files, "rb", { limit: 3 })
// [{ item: "RequestBuilder.ts", score: ..., matched: true }, ...]
// 객체 배열 검색 — keyFn으로 검색 키 지정
const users = [{ id: 1, name: "Alice" }, { id: 2, name: "Alan" }, { id: 3, name: "Bob" }];
fuzzySearch(users, "al", { keyFn: u => u.name, threshold: 0.3 })
// [{ item: { id: 1, name: "Alice" }, score: 0.6, matched: true }, ...]
// 오타 허용 검색 (threshold 낮게 설정)
fuzzySearch(["Alice", "Bob", "Charlie"], "alic", { threshold: 0.4 })
// Alice가 최상단
| 함수 |
시그니처 |
설명 |
bfs |
bfs<T>(root: TreeNode<T>): T[] |
트리를 BFS(레벨 순서)로 탐색하여 값 배열 반환 |
dfs |
dfs<T>(node: TreeNode<T>): T[] |
트리를 DFS 후위 순회하여 값 배열 반환 |
import { bfs, dfs } from "simple-ts-tools";
// 1
// / \
// 2 3
// / \
// 4 5
const tree = { value: 1, children: [
{ value: 2, children: [
{ value: 4, children: [] },
{ value: 5, children: [] },
]},
{ value: 3, children: [] },
]};
// BFS — 레벨 순서 (너비 우선)
bfs(tree); // [1, 2, 3, 4, 5]
// DFS — 후위 순회 (깊이 우선)
dfs(tree); // [4, 5, 2, 3, 1]
// 활용: 최단 경로 탐색, 레벨별 처리에는 bfs
// 활용: 하위 노드부터 처리해야 할 때 (예: 트리 삭제, 크기 계산)는 dfs
| 함수 |
시그니처 |
설명 |
addDays |
addDays(date: Date, days: number): Date |
n일을 더한 새 Date 반환 |
addMonths |
addMonths(date: Date, months: number): Date |
n개월을 더한 새 Date (월말 자동 clamp) |
addYears |
addYears(date: Date, years: number): Date |
n년을 더한 새 Date |
subDays |
subDays(date: Date, days: number): Date |
n일을 뺀 새 Date 반환 |
startOfDay |
startOfDay(date: Date): Date |
하루의 시작 시각 (00:00:00.000) |
endOfDay |
endOfDay(date: Date): Date |
하루의 마지막 시각 (23:59:59.999) |
isSameDay |
isSameDay(a: Date, b: Date): boolean |
같은 날(연·월·일)인지 확인 (시각 무시) |
isWeekend |
isWeekend(date: Date): boolean |
토요일 또는 일요일인지 확인 |
isWeekday |
isWeekday(date: Date): boolean |
월~금인지 확인 |
isSameMonth |
isSameMonth(a: Date, b: Date): boolean |
같은 달(연·월)인지 확인 |
diffDays |
diffDays(a: Date, b: Date): number |
두 날짜의 일수 차이 (절댓값) |
startOfMonth |
startOfMonth(date: Date): Date |
해당 월의 첫날 00:00:00.000 |
endOfMonth |
endOfMonth(date: Date): Date |
해당 월의 마지막 날 23:59:59.999 |
startOfWeek |
startOfWeek(date: Date, weekStart?: 0|1): Date |
해당 주 시작일 00:00:00.000 (0=일, 1=월) |
endOfWeek |
endOfWeek(date: Date, weekStart?: 0|1): Date |
해당 주 마지막일 23:59:59.999 |
getQuarter |
getQuarter(date: Date): 1|2|3|4 |
분기 반환 (1~4) |
formatDate |
formatDate(date: Date, format: string): string |
토큰 기반 날짜 포맷 변환 |
formatRelativeTime |
formatRelativeTime(date: Date, base?: Date, locale?: "ko"|"en"): string |
상대 시간 표시 ("3분 전", "2일 후") |
parseDate |
parseDate(input: string, locale?: "en-US"|"en-GB"): Date | null |
다양한 형식의 날짜 문자열 → Date (실패 시 null) |
formatDuration |
formatDuration(ms: number, options?): string |
밀리초 → 사람이 읽기 좋은 시간 문자열 (한/영 지원) |
dateRange |
dateRange(start: Date, end: Date, step?: number): Date[] |
두 날짜 사이의 날짜 배열 (양 끝 포함, step은 일 단위) |
monthRange |
monthRange(start: Date, end: Date): Date[] |
두 날짜 사이의 월 배열 (각 월의 1일 00:00:00) |
addBusinessDays |
addBusinessDays(date: Date, days: number): Date |
주말을 건너뛰어 n 영업일 후/전 날짜 반환 (음수 지원) |
getBusinessDayCount |
getBusinessDayCount(a: Date, b: Date): number |
두 날짜 사이의 영업일 수 (양 끝 포함, 주말 제외) |
nextBusinessDay |
nextBusinessDay(date: Date): Date |
가장 가까운 다음 영업일 (이미 영업일이면 그대로) |
prevBusinessDay |
prevBusinessDay(date: Date): Date |
가장 가까운 이전 영업일 (이미 영업일이면 그대로) |
지원 토큰
| 토큰 |
설명 |
예시 |
YYYY |
4자리 연도 |
2024 |
YY |
2자리 연도 |
24 |
MM |
2자리 월 |
06 |
M |
월 (패딩 없음) |
6 |
DD |
2자리 일 |
07 |
D |
일 (패딩 없음) |
7 |
HH |
24시간제 시 |
09 |
H |
24시간제 시 (패딩 없음) |
9 |
hh |
12시간제 시 |
09 |
h |
12시간제 시 (패딩 없음) |
9 |
mm |
분 |
05 |
ss |
초 |
03 |
A |
AM / PM |
PM |
a |
am / pm |
pm |
import { formatDate } from "simple-ts-tools";
const now = new Date("2024-06-07T09:05:03");
formatDate(now, "YYYY-MM-DD"); // "2024-06-07"
formatDate(now, "YYYY년 M월 D일"); // "2024년 6월 7일"
formatDate(now, "HH:mm:ss"); // "09:05:03"
formatDate(now, "YYYY-MM-DD HH:mm:ss"); // "2024-06-07 09:05:03"
const afternoon = new Date("2024-06-07T14:30:00");
formatDate(afternoon, "h:mm A"); // "2:30 PM"
formatDate(afternoon, "hh:mm a"); // "02:30 pm"
// 파일명, 로그 타임스탬프
`log_${formatDate(new Date(), "YYYY-MM-DD")}.txt` // "log_2024-06-07.txt"
// 날짜 조작 — 원본 불변
const today = new Date("2024-06-07");
addDays(today, 7); // 2024-06-14 (today는 변하지 않음)
subDays(today, 3); // 2024-06-04
startOfDay(today); // 2024-06-07T00:00:00.000
endOfDay(today); // 2024-06-07T23:59:59.999
// 날짜 비교 / 필터링
isSameDay(new Date(), new Date()); // true
diffDays(new Date("2024-01-01"), new Date("2024-01-31")); // 30
// 활용 패턴
const todayPosts = posts.filter(p => isSameDay(p.createdAt, new Date()));
const weekAgo = subDays(new Date(), 7);
const thisWeekPosts = posts.filter(p => p.createdAt >= weekAgo);
// 날짜 범위 쿼리
const start = startOfDay(selectedDate);
const end = endOfDay(selectedDate);
db.query({ createdAt: { gte: start, lte: end } });
// 월/년 단위 조작 — 월말 날짜도 안전하게 처리
addMonths(new Date("2024-01-31"), 1); // 2024-02-29 (윤년 clamp)
addMonths(new Date("2024-03-31"), -1); // 2024-02-29
addYears(new Date("2024-02-29"), 1); // 2025-02-28 (비윤년 clamp)
// 주말/평일 필터링
const workdays = dateRange.filter(isWeekday);
const weekends = dateRange.filter(isWeekend);
const nextBizDay = isWeekday(tomorrow) ? tomorrow : addDays(tomorrow, isWeekend(tomorrow) && tomorrow.getDay() === 6 ? 2 : 1);
// 상대 시간 표시 (SNS 피드, 댓글, 알림)
const now = new Date();
formatRelativeTime(new Date(now.getTime() - 30 * 1000)); // "방금 전"
formatRelativeTime(new Date(now.getTime() - 3 * 60 * 1000)); // "3분 전"
formatRelativeTime(new Date(now.getTime() - 2 * 3600 * 1000)); // "2시간 전"
formatRelativeTime(new Date(now.getTime() - 5 * 86400 * 1000)); // "5일 전"
formatRelativeTime(new Date(now.getTime() + 86400 * 1000)); // "1일 후"
// 영어 지원
formatRelativeTime(pastDate, now, "en"); // "3 minutes ago"
formatRelativeTime(futureDate, now, "en"); // "in 2 hours"
// parseDate — 다양한 포맷 파싱 (성공 시 Date, 실패 시 null)
parseDate("2024-06-07"); // Date (2024년 6월 7일)
parseDate("2024/06/07"); // Date
parseDate("2024.06.07"); // Date
parseDate("2024년 6월 7일"); // Date (한국어)
parseDate("2024년6월7일"); // Date (공백 없음도 지원)
parseDate("06/07/2024"); // Date (en-US: MM/DD/YYYY)
parseDate("07/06/2024", "en-GB"); // Date (en-GB: DD/MM/YYYY)
parseDate("2024-02-30"); // null (존재하지 않는 날짜)
parseDate("not-a-date"); // null
// API 응답의 날짜 문자열을 안전하게 파싱
const date = parseDate(apiResponse.createdAt);
if (date) formatRelativeTime(date);
// 월 범위 — 캘린더, 대시보드 데이터 범위
const today = new Date("2024-06-15");
startOfMonth(today); // 2024-06-01T00:00:00.000
endOfMonth(today); // 2024-06-30T23:59:59.999
endOfMonth(new Date("2024-02-01")); // 2024-02-29 (윤년)
// 주 범위 — 주간 통계, 캘린더 뷰
startOfWeek(today); // 2024-06-09 (일요일 기준)
startOfWeek(today, 1); // 2024-06-10 (월요일 기준)
endOfWeek(today); // 2024-06-15 (토요일)
endOfWeek(today, 1); // 2024-06-16 (일요일)
// 분기 — 분기 보고서, 재무 데이터
getQuarter(new Date("2024-01-15")); // 1
getQuarter(new Date("2024-04-01")); // 2
getQuarter(new Date("2024-07-31")); // 3
getQuarter(new Date("2024-10-01")); // 4
// 같은 달 확인 — 월별 집계 그룹핑
isSameMonth(new Date("2024-06-01"), new Date("2024-06-30")); // true
isSameMonth(new Date("2024-06-01"), new Date("2024-07-01")); // false
// 실사용: 이번 달 매출 쿼리
const [from, to] = [startOfMonth(new Date()), endOfMonth(new Date())];
db.query("SELECT * FROM orders WHERE created_at BETWEEN ? AND ?", [from, to]);
// dateRange — 날짜 배열 생성 (캘린더, 차트, 스케줄)
dateRange(new Date("2024-06-01"), new Date("2024-06-05"))
// [Jun1, Jun2, Jun3, Jun4, Jun5] (시각은 00:00:00으로 정규화)
// step으로 간격 조정
dateRange(new Date("2024-06-01"), new Date("2024-06-10"), 3)
// [Jun1, Jun4, Jun7, Jun10]
// 실사용: 캘린더 월간 뷰
const days = dateRange(startOfMonth(now), endOfMonth(now));
// 30개 날짜 → 각 칸에 날짜/이벤트 렌더링
// 실사용: 최근 7일 차트 X축
const xAxis = dateRange(subDays(new Date(), 6), new Date());
// 7개 Date → formatDate로 레이블 생성
// 실사용: 주간 스케줄 뷰 (step=7)
const weeks = dateRange(startOfYear, endOfYear, 7);
// monthRange — 월 배열 생성 (연간 차트, 월간 리포트)
monthRange(new Date("2024-01-15"), new Date("2024-04-10"))
// [Jan1, Feb1, Mar1, Apr1] — 각 월의 1일로 정규화
// 실사용: 연간 12개월 매출 차트
const months = monthRange(new Date("2024-01-01"), new Date("2024-12-31"));
months.map(m => ({ label: formatDate(m, "M월"), value: getSalesForMonth(m) }));
// 실사용: 최근 6개월 추이
const sixMonths = monthRange(addMonths(new Date(), -5), new Date());
// 영업일 계산 — 주말(토·일)을 자동으로 건너뜀
// 금요일 + 1 영업일 = 다음 주 월요일
addBusinessDays(new Date("2024-06-07"), 1); // 2024-06-10 (월)
addBusinessDays(new Date("2024-06-07"), 3); // 2024-06-12 (수)
// 음수 — n 영업일 전
addBusinessDays(new Date("2024-06-10"), -3); // 2024-06-05 (수)
// 영업일 수 계산 (양 끝 포함)
getBusinessDayCount(new Date("2024-06-10"), new Date("2024-06-14")); // 5 (월~금)
getBusinessDayCount(new Date("2024-06-10"), new Date("2024-06-21")); // 10 (2주)
// 주말이면 다음/이전 영업일로 조정
nextBusinessDay(new Date("2024-06-08")); // 2024-06-10 (토 → 월)
prevBusinessDay(new Date("2024-06-09")); // 2024-06-07 (일 → 금)
// 실사용: e-커머스 배송 예상일 (주문일 기준 3 영업일 후)
const deliveryDate = addBusinessDays(orderDate, 3);
`예상 배송일: ${formatDate(deliveryDate, "M월 D일 (ddd)")}`;
// 실사용: 계약 만료 30 영업일 전 알림 발송
const alertDate = addBusinessDays(contractExpiry, -30);
if (isSameDay(alertDate, new Date())) sendRenewalAlert();
// 실사용: SLA 기준 처리 기한 계산
const deadline = addBusinessDays(ticketCreatedAt, 5); // 5 영업일 이내 처리
// 실사용: 프로젝트 일정 (공휴일 미포함)
const sprintEnd = addBusinessDays(sprintStart, 10); // 2주 스프린트
const workingDays = getBusinessDayCount(projectStart, projectEnd);
`총 ${workingDays} 영업일`;
// formatDuration — 밀리초 → 사람이 읽기 좋은 시간 문자열
formatDuration(90_000); // "1분 30초"
formatDuration(3_661_000); // "1시간 1분" (기본 2단위)
formatDuration(3_661_000, { parts: 3 }); // "1시간 1분 1초"
formatDuration(86_400_000); // "1일"
formatDuration(500); // "< 1초"
formatDuration(-90_000); // "1분 30초" (음수 → 절댓값)
// 영어 지원
formatDuration(90_000, { locale: "en" }); // "1m 30s"
formatDuration(3_661_000, { locale: "en", parts: 3 }); // "1h 1m 1s"
formatDuration(500, { locale: "en" }); // "< 1s"
// 실사용: 동영상 길이 표시
formatDuration(5_400_000); // "1시간 30분"
formatDuration(600_000); // "10분"
// ETA(남은 시간) 표시
const etaMs = uploadedBytes === 0 ? 0 : (totalBytes - uploadedBytes) / speed * 1000;
`남은 시간: ${formatDuration(etaMs)}`; // "2시간 3분"
// 타이머 표시 — 단일 단위
formatDuration(3_000, { parts: 1 }); // "3초"
| 클래스 |
설명 |
LRUCache<K, V> |
용량 제한 캐시. 초과 시 가장 오래 전에 사용된 항목 자동 제거 |
TTLMap<K, V> |
TTL 기반 Map. 항목별 만료 시간 설정, 자동 만료. Rate limit·중복 방지·Negative 캐시에 적합 |
| 메서드 / 프로퍼티 |
설명 |
new LRUCache(capacity) |
최대 저장 항목 수 지정 (양의 정수) |
.get(key) |
값 반환, 없으면 undefined. 조회 시 최근 사용으로 갱신 |
.set(key, value) |
값 저장. 기존 키면 갱신 후 최근 사용으로 이동. 체이닝 가능 |
.has(key) |
키 존재 여부 확인 (순서 변경 없음) |
.delete(key) |
항목 제거, 성공 여부 반환 |
.clear() |
전체 비우기 |
.size |
현재 항목 수 |
내부적으로 Map의 삽입 순서를 활용해 get/set 모두 O(1) 으로 동작한다.
import { LRUCache } from "simple-ts-tools";
// API 응답 캐싱 — 최대 100개, 초과 시 오래된 것부터 제거
const apiCache = new LRUCache<string, Response>(100);
async function fetchUser(id: string) {
const cached = apiCache.get(id);
if (cached) return cached;
const data = await fetch(`/api/users/${id}`).then(r => r.json());
apiCache.set(id, data);
return data;
}
// 계산 비용이 큰 함수 결과 캐싱
const imgCache = new LRUCache<string, HTMLImageElement>(50);
// 연속된 조회에서 LRU 동작 확인
const cache = new LRUCache<string, number>(2);
cache.set("a", 1).set("b", 2);
cache.get("a"); // "a"가 최근 사용으로 갱신
cache.set("c", 3); // 용량 초과 → "b"(가장 오래됨)가 제거
cache.has("b"); // false
cache.has("a"); // true
TTLMap — 시간 기반 만료 Map (LRUCache는 개수 기반 제거, TTLMap은 시간 기반 만료)
| 메서드 / 프로퍼티 |
설명 |
new TTLMap(defaultTtl) |
기본 TTL(ms) 지정 |
.set(key, value, { ttl? }) |
값 저장. ttl 지정 시 이 항목에만 개별 TTL 적용. 체이닝 가능 |
.get(key) |
값 반환. 만료됐으면 undefined 반환 후 항목 삭제 |
.has(key) |
유효한 항목이 있으면 true. 만료됐으면 false 후 삭제 |
.delete(key) |
항목 제거, 성공 여부 반환 |
.clear() |
전체 비우기 |
.cleanup() |
만료된 항목 일괄 제거. 제거된 항목 수 반환 |
.ttl(key) |
특정 키의 남은 TTL(ms). 없거나 만료됐으면 0 |
.size |
현재 저장된 항목 수 (만료됐지만 cleanup 전 항목 포함) |
import { TTLMap } from "simple-ts-tools";
// 중복 이벤트 방지 — 같은 이벤트 ID는 1분간 무시
const processed = new TTLMap<string, true>(60_000);
function handleEvent(id: string) {
if (processed.has(id)) return; // 이미 처리됨
processed.set(id, true);
// ... 처리
}
// Rate limiting — 사용자별 1분에 최대 100회
const requestCounts = new TTLMap<string, number>(60_000);
function isRateLimited(userId: string): boolean {
const count = requestCounts.get(userId) ?? 0;
if (count >= 100) return true;
requestCounts.set(userId, count + 1);
return false;
}
// Negative 캐시 — 404 응답을 30초간 캐시해 재요청 차단
const notFound = new TTLMap<string, true>(30_000);
async function fetchResource(url: string) {
if (notFound.has(url)) throw new Error("Not Found (cached)");
const res = await fetch(url);
if (res.status === 404) notFound.set(url, true);
return res;
}
// 항목별 TTL — 중요도에 따라 다른 만료 시간
const tokenCache = new TTLMap<string, string>(15 * 60_000); // 기본 15분
tokenCache.set("access-token", token, { ttl: 5 * 60_000 }); // 5분
tokenCache.set("refresh-token", refresh, { ttl: 7 * 24 * 60 * 60_000 }); // 7일
// 장시간 실행 서버에서 주기적 정리
setInterval(() => tokenCache.cleanup(), 60_000);
| 클래스 |
설명 |
PriorityQueue<T> |
이진 최소 힙 기반 우선순위 큐. priority 낮을수록 먼저 꺼냄. 동일 priority는 FIFO |
Queue<T> |
FIFO 큐. dequeue O(1) (head 포인터 방식) |
Stack<T> |
LIFO 스택. 모든 연산 O(1) |
Trie |
접두사 트리. insert/search/delete O(L), suggest(prefix) 사전순 자동완성 O(P+K) |
PriorityQueue 메서드
| 메서드 / 프로퍼티 |
시그니처 |
설명 |
enqueue(value, priority) |
(value: T, priority: number): void |
O(log n) 삽입 |
dequeue() |
(): T | undefined |
O(log n) — 최소 priority 값 꺼내기 |
peek() |
(): T | undefined |
O(1) — 제거 없이 다음 값 확인 |
toArray() |
(): T[] |
우선순위 순으로 배열 반환 (비파괴) |
.size |
number |
현재 요소 수 |
.isEmpty |
boolean |
비어있는지 확인 |
| 메서드 / 프로퍼티 |
Queue |
Stack |
설명 |
enqueue(item) / push(item) |
✓ |
✓ |
요소 추가, 체이닝 가능 |
dequeue() / pop() |
✓ |
✓ |
요소 꺼내기 (없으면 undefined) |
peek() |
✓ |
✓ |
제거 없이 다음 요소 확인 |
.isEmpty |
✓ |
✓ |
비어 있는지 확인 |
.size |
✓ |
✓ |
현재 요소 수 |
clear() |
✓ |
✓ |
전체 비우기 |
toArray() |
✓ |
✓ |
배열로 변환 |
import { PriorityQueue, Queue, Stack } from "simple-ts-tools";
// 작업 스케줄러 — 긴급도 기반 처리 (낮은 숫자 = 높은 우선순위)
const scheduler = new PriorityQueue<Task>();
scheduler.enqueue(sendEmail, priority: 3);
scheduler.enqueue(processPayment, priority: 1); // 먼저 처리됨
scheduler.enqueue(updateCache, priority: 2);
while (!scheduler.isEmpty) {
const task = scheduler.dequeue()!;
await task.run();
}
// 다익스트라 알고리즘 — 최단 거리 노드 우선 탐색
const pq = new PriorityQueue<string>();
pq.enqueue("A", 0);
while (!pq.isEmpty) {
const node = pq.dequeue()!;
for (const [next, cost] of graph[node]) {
pq.enqueue(next, dist[node] + cost);
}
}
// 같은 priority → enqueue 순서 유지 (FIFO tiebreak)
const pq2 = new PriorityQueue<string>();
pq2.enqueue("first", 5);
pq2.enqueue("second", 5);
pq2.dequeue(); // "first"
// BFS 직접 구현
const queue = new Queue<TreeNode>([root]);
while (!queue.isEmpty) {
const node = queue.dequeue()!;
process(node);
node.children.forEach(c => queue.enqueue(c));
}
// 히스토리 / undo 스택
const history = new Stack<State>();
history.push(currentState);
const prev = history.pop(); // undo
// 괄호 유효성 검사
function isBalanced(s: string) {
const stack = new Stack<string>();
for (const ch of s) {
if ("({[".includes(ch)) stack.push(ch);
else if (")}]".includes(ch) && stack.pop() !== { ")":"(", "}":"{", "]":"[" }[ch])
return false;
}
return stack.isEmpty;
}
Trie 메서드
| 메서드 / 프로퍼티 |
시그니처 |
설명 |
new Trie(words?) |
(words?: string[]) |
초기 단어 배열로 생성 가능 |
.insert(word) |
(word: string): this |
O(L) 삽입. 중복 무시. 체이닝 가능 |
.search(word) |
(word: string): boolean |
O(L) 정확히 일치하는 단어가 있으면 true |
.startsWith(prefix) |
(prefix: string): boolean |
O(L) 해당 접두사로 시작하는 단어가 있으면 true |
.suggest(prefix?, limit?) |
(prefix?: string, limit?: number): string[] |
O(P+K) 접두사로 시작하는 단어를 사전순으로 반환. limit으로 수 제한 |
.delete(word) |
(word: string): boolean |
O(L) 단어 삭제. 성공 여부 반환 |
.toArray() |
(): string[] |
모든 단어 사전순 반환 |
.size |
number |
저장된 단어 수 |
.isEmpty |
boolean |
비어있는지 확인 |
import { Trie } from "simple-ts-tools";
// 검색창 자동완성
const trie = new Trie(["react", "react-dom", "react-router", "redux", "recoil"]);
trie.suggest("re"); // ["react", "react-dom", "react-router", "recoil", "redux"]
trie.suggest("redu"); // ["redux"]
trie.suggest("re", 3); // 최대 3개만 반환 (드롭다운 UI 성능 최적화)
// 단어 존재 여부 확인
trie.search("react"); // true
trie.search("reac"); // false — 접두사는 false
trie.startsWith("reac"); // true — 접두사 확인은 startsWith
// 항목 삭제 — 검색 기록 제거
trie.delete("react-dom");
trie.suggest("react"); // ["react", "react-router"]
// 금지어 필터
const blocklist = new Trie(["spam", "scam"]);
const hasBlocked = (text: string) =>
text.split(" ").some(w => blocklist.search(w.toLowerCase()));
// 파일 경로 탐색
const fileTrie = new Trie([
"src/components/Button.tsx",
"src/components/Input.tsx",
"src/hooks/useAuth.ts",
]);
fileTrie.suggest("src/components");
// ["src/components/Button.tsx", "src/components/Input.tsx"]
// 전체 단어 사전순 조회
trie.toArray(); // 모든 단어를 사전순으로
| 함수 |
시그니처 |
설명 |
parseQueryString |
parseQueryString(query: string): Record<string, string | string[]> |
쿼리 문자열 → 객체 (중복 키는 배열) |
buildQueryString |
buildQueryString(params: QueryParams): string |
객체 → 쿼리 문자열 (null/undefined 제외) |
import { parseQueryString, buildQueryString } from "simple-ts-tools";
// 파싱 — 앞의 ? 유무 무관
parseQueryString("?page=1&sort=name");
// { page: "1", sort: "name" }
parseQueryString("tags=a&tags=b&tags=c");
// { tags: ["a", "b", "c"] } — 중복 키는 자동으로 배열
parseQueryString("q=hello%20world");
// { q: "hello world" } — URL 디코딩 자동
// 직렬화 — null/undefined 자동 제외
buildQueryString({ page: 1, sort: "name" });
// "page=1&sort=name"
buildQueryString({ tags: ["a", "b", "c"] });
// "tags=a&tags=b&tags=c"
buildQueryString({ page: 1, filter: null, sort: undefined });
// "page=1" — null/undefined 제외
// 활용: 현재 URL에 파라미터 추가/수정
const current = parseQueryString(location.search);
const updated = buildQueryString({ ...current, page: 2 });
router.push(`/list?${updated}`);
컴포저블 스키마 검증. Rule<T> 배열로 스키마를 정의하고, validate()로 한 번에 실행한다.
각 필드에서 첫 번째로 실패한 규칙의 메시지만 반환한다.
내장 규칙
| 규칙 |
설명 |
required(msg?) |
null · undefined · 빈 문자열 · 빈 배열 거부 |
minLength(n, msg?) |
문자열 최소 길이 |
maxLength(n, msg?) |
문자열 최대 길이 |
minValue(n, msg?) |
숫자 최솟값 |
maxValue(n, msg?) |
숫자 최댓값 |
pattern(regex, msg?) |
정규식 패턴 |
emailRule(msg?) |
이메일 형식 |
urlRule(msg?) |
URL 형식 (new URL() 기반) |
minItems(n, msg?) |
배열 최소 항목 수 |
maxItems(n, msg?) |
배열 최대 항목 수 |
oneOf(allowed, msg?) |
허용 값 목록 |
custom(predicate, msg) |
커스텀 조건 함수 |
import { validate, required, minLength, maxLength, emailRule, minValue, oneOf, custom } from "simple-ts-tools";
// 회원가입 폼 스키마 정의
const signupSchema = {
username: [
required(),
minLength(3),
maxLength(20),
pattern(/^\w+$/, "영문·숫자·_만 사용 가능합니다"),
custom(v => v !== "admin", "예약된 이름은 사용할 수 없습니다"),
],
email: [required(), emailRule()],
password: [required(), minLength(8, "비밀번호는 8자 이상이어야 합니다")],
role: [oneOf(["user", "admin"])],
};
const result = validate(formData, signupSchema);
if (result.valid) {
// 타입 좁혀짐: valid === true이면 errors 없음
submitForm(formData);
} else {
// errors 객체: 각 필드 → 첫 번째 실패 메시지
setErrors(result.errors);
// { username: "3자 이상 입력해주세요", email: "이메일 형식이 올바르지 않습니다" }
}
// 커스텀 규칙 조합 — 비밀번호 확인
const pwSchema = {
password: [required(), minLength(8)],
passwordConfirm: [
required(),
custom(v => v === formData.password, "비밀번호가 일치하지 않습니다"),
],
};
// 배열 필드 검증
const tagSchema = {
tags: [required(), minItems(1, "태그를 1개 이상 선택해주세요"), maxItems(5)],
};
// API 입력 검증에도 동일하게 사용
function createPost(body: unknown) {
const result = validate(body as Record<string, unknown>, {
title: [required(), minLength(1), maxLength(100)],
content: [required(), minLength(10)],
});
if (!result.valid) throw new Error(JSON.stringify(result.errors));
}
RFC 4180 기반 CSV 파서/포매터. quoted field(따옴표 내 쉼표·줄바꿈·이스케이프), 커스텀 구분자, 헤더 행, CRLF를 모두 지원한다. csv-parse 같은 외부 의존성 없이 일반적인 CSV 처리를 커버한다.
| 함수 |
시그니처 |
설명 |
parseCSV |
parseCSV(input, options?): Record<string, string>[] |
CSV 문자열 → 레코드 배열 (header: true) |
parseCSV |
parseCSV(input, { header: false }): string[][] |
CSV 문자열 → 2차원 배열 |
formatCSV |
formatCSV(data, options?): string |
레코드 배열 또는 2차원 배열 → CSV 문자열 |
ParseOptions: header (기본 true) · delimiter (기본 ",") · trim (기본 true) · skipEmptyLines (기본 true)
FormatOptions: delimiter · lineBreak ("\n" / "\r\n")
import { parseCSV, formatCSV } from "simple-ts-tools";
// 파싱 — 헤더 포함 (기본)
parseCSV(`name,age\nAlice,30\nBob,25`)
// [{ name: "Alice", age: "30" }, { name: "Bob", age: "25" }]
// quoted field — 쉼표, 줄바꿈, 따옴표 이스케이프 모두 처리
parseCSV(`name,address\nAlice,"Seoul, Korea"`)
// [{ name: "Alice", address: "Seoul, Korea" }]
parseCSV(`bio\n"He said ""hello"""`)
// [{ bio: 'He said "hello"' }]
// 커스텀 구분자
parseCSV("a;b\n1;2", { delimiter: ";" })
// [{ a: "1", b: "2" }]
// 2차원 배열로 파싱
parseCSV("a,b\n1,2", { header: false })
// [["a","b"], ["1","2"]]
// 포매팅 — Record 배열 → CSV
formatCSV([{ name: "Alice", age: 30 }, { name: "Bob", age: 25 }])
// "name,age\nAlice,30\nBob,25"
// 특수문자 자동 인용
formatCSV([{ city: "Seoul, Korea", bio: 'He said "hi"' }])
// 'city,bio\n"Seoul, Korea","He said ""hi"""'
// parseCSV ↔ formatCSV 왕복 변환 (quoted fields 포함)
const csv = formatCSV(records);
const restored = parseCSV(csv); // 원본 복원
Unicode-safe Base64 / Base64URL 인코딩·디코딩. btoa/atob의 두 가지 문제(한글·이모지 등 ASCII 범위 밖 문자 throw, URL-safe 변형 없음)를 해결한다. 브라우저, Node.js(v16+), Edge Runtime 모두 호환.
| 함수 |
시그니처 |
설명 |
encodeBase64 |
encodeBase64(input: string): string |
문자열 → Base64 (유니코드 안전, TextEncoder 사용) |
decodeBase64 |
decodeBase64(input: string): string |
Base64 → 문자열 (Base64URL 형식도 허용) |
encodeBase64Url |
encodeBase64Url(input: string): string |
문자열 → Base64URL (+→-, /→_, 패딩 제거) |
decodeBase64Url |
decodeBase64Url(input: string): string |
Base64URL → 문자열 (패딩 없어도 자동 복원) |
bytesToBase64 |
bytesToBase64(bytes: Uint8Array): string |
바이트 배열 → Base64 |
base64ToBytes |
base64ToBytes(input: string): Uint8Array |
Base64 → 바이트 배열 |
bytesToBase64Url |
bytesToBase64Url(bytes: Uint8Array): string |
바이트 배열 → Base64URL |
isValidBase64 |
isValidBase64(input: string): boolean |
Base64 / Base64URL 유효성 검사 |
import { encodeBase64, decodeBase64, encodeBase64Url, decodeBase64Url, bytesToBase64 } from "simple-ts-tools";
// 기본 Base64 — 유니코드 안전
encodeBase64("Hello, World!") // "SGVsbG8sIFdvcmxkIQ=="
encodeBase64("안녕하세요") // UTF-8로 변환 후 인코딩 (btoa는 throw)
encodeBase64("🎉🚀") // 이모지도 안전하게 처리
decodeBase64("SGVsbG8sIFdvcmxkIQ==") // "Hello, World!"
// Base64URL — JWT, 쿠키, URL 파라미터
encodeBase64Url("Hello") // 패딩 없음, + / 없음 → URL에 직접 삽입 가능
// JWT 페이로드 수동 인코딩/디코딩
const payload = { sub: "user_123", exp: 9999999999 };
const encoded = encodeBase64Url(JSON.stringify(payload));
// → URL-safe 문자만 포함, 패딩 없음
const decoded = JSON.parse(decodeBase64Url(jwtToken.split(".")[1]));
// 바이너리 데이터 — 이미지 인라인
const imageBytes = new Uint8Array([137, 80, 78, 71, ...]);
const dataUrl = `data:image/png;base64,${bytesToBase64(imageBytes)}`;
// Web Crypto API 결과 직렬화
const hash = await crypto.subtle.digest("SHA-256", data);
const hashBase64 = bytesToBase64Url(new Uint8Array(hash)); // URL-safe hash
HEX ↔ RGB ↔ HSL 변환, 밝기·채도·투명도 조작, 색상 혼합. 별도 의존성 없이 UI 프로젝트에서 가장 자주 쓰는 색상 유틸리티를 제공한다.
#RGB, #RRGGBB, #RGBA, #RRGGBBAA 형식을 모두 지원한다.
| 함수 |
시그니처 |
설명 |
hexToRgb |
hexToRgb(hex): { r, g, b, a } |
HEX → RGB(A) 객체 |
rgbToHex |
rgbToHex(r, g, b, a?): string |
RGB(A) → HEX 문자열 (alpha < 1이면 8자리) |
hexToHsl |
hexToHsl(hex): { h, s, l, a } |
HEX → HSL(A) 객체 (h: 0–360, s/l: 0–100) |
hslToHex |
hslToHex(h, s, l, a?): string |
HSL(A) → HEX 문자열 |
lighten |
lighten(hex, amount): string |
lightness +amount×100%p (0–1) |
darken |
darken(hex, amount): string |
lightness -amount×100%p (0–1) |
saturate |
saturate(hex, amount): string |
saturation +amount×100%p |
desaturate |
desaturate(hex, amount): string |
saturation -amount×100%p |
setAlpha |
setAlpha(hex, opacity): string |
투명도 설정 (0–1) |
mix |
mix(hex1, hex2, weight?): string |
두 색 혼합 (weight=0→hex1, 1→hex2, 기본 0.5) |
complement |
complement(hex): string |
보색 (hue + 180°) |
isLight |
isLight(hex): boolean |
W3C WCAG 휘도 기준 밝은 색 판별 |
import { hexToRgb, lighten, darken, mix, isLight, setAlpha, complement } from "simple-ts-tools";
// 변환
hexToRgb("#ff6600") // { r: 255, g: 102, b: 0, a: 1 }
hexToRgb("#f60") // { r: 255, g: 102, b: 0, a: 1 }
rgbToHex(255, 102, 0, 0.5) // "#ff660080"
// 밝기 조절 — 디자인 시스템 색상 변형
lighten("#336699", 0.2) // lightness +20%p
darken("#336699", 0.2) // lightness -20%p
// 투명도
setAlpha("#ff6600", 0.5) // "#ff660080"
setAlpha("#ff660080", 1) // "#ff6600" (alpha 제거)
// 혼합
mix("#ff0000", "#0000ff") // "#800080" (보라, 50:50)
mix("#ff0000", "#0000ff", 0.25) // 빨강 75% + 파랑 25%
// 접근성 — 배경색에 맞는 텍스트 색 자동 선택
const textColor = isLight(bgHex) ? "#000000" : "#ffffff";
// 보색
complement("#ff6600") // "#0099ff"
// 채도
saturate("#7f9f7f", 0.5) // 더 선명하게
desaturate("#ff6600", 0.5) // 더 회색빛으로
Node.js 환경변수를 스키마 기반으로 파싱·검증하는 유틸리티. 타입 추론이 완전하게 동작하며, 누락된 필수 변수는 앱 시작 시 즉시 throw (fail fast).
| 함수 |
시그니처 |
설명 |
parseEnv |
parseEnv<S>(schema, source?): InferEnv<S> |
스키마에 따라 환경변수 파싱·검증. source 미지정 시 process.env 사용 |
지원 타입
type |
변환 |
비고 |
"string" |
그대로 |
— |
"number" |
Number() |
min / max 범위 검사 가능 |
"boolean" |
true/false |
"true"/"1"/"yes"/"on" → true, "false"/"0"/"no"/"off" → false |
"enum" |
허용 목록 검사 |
values: [...] as const 으로 유니온 타입 추론 |
옵션: default 지정 → 항상 해당 타입 / optional: true → Type | undefined / 둘 다 없음 → required (없으면 throw)
import { parseEnv } from "simple-ts-tools";
const env = parseEnv({
PORT: { type: "number", default: 3000 },
DATABASE_URL: { type: "string" }, // required — 없으면 즉시 throw
DEBUG: { type: "boolean", default: false },
LOG_LEVEL: {
type: "enum",
values: ["debug", "info", "warn", "error"] as const,
default: "info",
},
REDIS_URL: { type: "string", optional: true }, // string | undefined
});
// 타입 추론:
// env.PORT → number
// env.DATABASE_URL → string
// env.DEBUG → boolean
// env.LOG_LEVEL → "debug" | "info" | "warn" | "error"
// env.REDIS_URL → string | undefined
// 범위 검사
parseEnv({ PORT: { type: "number", min: 1024, max: 65535 } });
// 여러 필드 오류는 한 번에 보고
// Error: Environment variable validation failed:
// • PORT: required but not set
// • DATABASE_URL: required but not set
// 테스트에서 source 직접 주입 (process.env 불필요)
const testEnv = parseEnv(schema, { DATABASE_URL: "postgres://localhost/test", PORT: "5432" });
암호학적으로 안전한 랜덤 ID 생성. Math.random() 기반 구현의 편향·충돌 위험 없이 crypto.getRandomValues()를 사용한다. 브라우저, Node.js(v15+), Edge Runtime 모두 지원.
| 함수 |
시그니처 |
설명 |
createId |
createId(options?): string |
URL-safe 랜덤 ID 생성. 길이·문자셋 커스터마이징 가능 (기본: 21자, A-Za-z0-9_-) |
createUUID |
createUUID(): string |
RFC 4122 UUID v4 형식 생성 (xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx) |
import { createId, createUUID } from "simple-ts-tools";
// 기본 — 21자 URL-safe ID (nanoid 호환)
createId() // "V1StGXR8_Z5jdHi6B-myT"
// 커스텀 길이
createId({ length: 10 }) // "K7xQpL3mNw"
// 커스텀 문자셋 — 16진수 ID
createId({ length: 8, alphabet: "0123456789abcdef" }) // "3f9a1c2e"
// 숫자만 (OTP 코드, 주문 번호 등)
createId({ length: 6, alphabet: "0123456789" }) // "847392"
// UUID v4 — DB primary key, 외부 API 연동
createUUID() // "550e8400-e29b-41d4-a716-446655440000"
| 함수 |
시그니처 |
설명 |
createLogger |
createLogger(options?): Logger |
구조화된 로거 생성 — 레벨 필터링, 컨텍스트 병합, child 로거, 복수 트랜스포트 지원 |
consoleTransport |
consoleTransport(options?): Transport |
사람이 읽기 쉬운 콘솔 출력 트랜스포트 (ANSI 컬러 옵션) |
jsonTransport |
jsonTransport(): Transport |
JSON Lines 형식으로 출력하는 트랜스포트 (로그 수집기 연동용) |
LoggerOptions
| 옵션 |
타입 |
기본값 |
설명 |
level |
LogLevel |
"info" |
최소 출력 레벨 ("debug" < "info" < "warn" < "error" < "silent") |
namespace |
string |
— |
로거 네임스페이스. child() 시 parent:child 형태로 합쳐짐 |
transports |
Transport[] |
[consoleTransport()] |
로그 엔트리를 수신할 트랜스포트 함수 배열 |
context |
Record<string, unknown> |
— |
모든 로그 엔트리에 자동으로 병합될 기본 컨텍스트 |
Logger 인터페이스
| 메서드 |
설명 |
debug / info / warn / error(message, context?, error?) |
각 레벨로 로그 출력 |
child(context, namespace?) |
컨텍스트를 상속하는 자식 로거 반환 |
level |
현재 레벨 조회 |
setLevel(level) |
레벨 동적 변경 |
import { createLogger, consoleTransport, jsonTransport } from "simple-ts-tools";
// 기본 사용 — info 이상 출력
const log = createLogger({ level: "info" });
log.info("서버 시작", { port: 3000 });
log.error("DB 연결 실패", { host: "localhost" }, new Error("ECONNREFUSED"));
// 자식 로거 — 요청별 컨텍스트 분리
const reqLog = log.child({ requestId: "abc-123", userId: 42 });
reqLog.info("요청 처리 시작"); // requestId, userId 자동 포함
// 네임스페이스 계층 (app → app:db)
const appLog = createLogger({ namespace: "app" });
const dbLog = appLog.child({}, "db");
dbLog.warn("slow query", { duration: 450 }); // [app:db] slow query {"duration":450}
// 프로덕션 — JSON Lines로 출력 (Logstash, Fluentd, CloudWatch 등과 연동)
const prodLog = createLogger({
level: "info",
context: { service: "my-api", version: "1.2.0" },
transports: [jsonTransport()],
});
// 개발 — 컬러 콘솔 + 원격 전송 복합 사용
const devLog = createLogger({
level: "debug",
transports: [
consoleTransport({ colorize: true }),
(entry) => sendToRemote(entry), // 커스텀 트랜스포트
],
});
// 동적 레벨 변경 (런타임 디버그 활성화)
log.setLevel("debug");
pnpm test # 테스트 실행
pnpm test:watch # 테스트 감시 모드
pnpm build # 빌드 (dist/)
pnpm lint # 타입 체크