Skip to content

junzero741/simple-ts-tools

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

183 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

simple-ts-tools

개인 프로젝트에서 반복적으로 쓰이는 TypeScript 유틸리티 모음.

설치

pnpm add simple-ts-tools

모듈 목록

array

함수 시그니처 설명
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]

async

함수 시그니처 설명
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)`);
  }
}

event

클래스/함수 설명
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" });

function

함수 시그니처 설명
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
);

createStateMachine

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" },
  },
});

scan 예제

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

http

함수/클래스 설명
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 }>();

language

함수 시그니처 설명
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 — 인스턴스는 제외

number

함수 시그니처 설명
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;

object

함수 시그니처 설명
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 } }

result

타입 안전한 에러 처리 패턴. 함수가 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

네이티브 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);   // 제거된

storage

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:*" 항목만 삭제, 다른 앱 키 유지

phone

함수 시그니처 설명
formatPhoneNumber formatPhoneNumber(value: string): string 한국 전화번호를 하이픈 포맷으로 변환
formatPhoneNumber("01012345678"); // "010-1234-5678"
formatPhoneNumber("0212345678");  // "02-123-4567" (8자리 지역번호 형식)

string

함수 시그니처 설명
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>');
// '&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;'

escapeHtml('안녕 & "반가워" <br>');
// '안녕 &amp; &quot;반가워&quot; &lt;br&gt;'

// 역변환
unescapeHtml('&lt;b&gt;hello&lt;/b&gt;'); // '<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가 최상단

tree

함수 시그니처 설명
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

date

함수 시그니처 설명
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초"

cache

클래스 설명
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);

structure

클래스 설명
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(); // 모든 단어를 사전순으로

url

함수 시그니처 설명
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}`);

validation

컴포저블 스키마 검증. 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));
}

csv

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); // 원본 복원

encoding

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

color

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)  // 더 회색빛으로

env

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: trueType | 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

암호학적으로 안전한 랜덤 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"

logger

함수 시그니처 설명
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          # 타입 체크

About

간단한 TypeScript 유틸 함수 모음

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors