@mantiq/helpers 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/package.json +52 -0
- package/src/Arr.ts +264 -0
- package/src/Collection.ts +804 -0
- package/src/Duration.ts +172 -0
- package/src/Http.ts +573 -0
- package/src/HttpFake.ts +361 -0
- package/src/Num.ts +187 -0
- package/src/Result.ts +196 -0
- package/src/Str.ts +457 -0
- package/src/async.ts +209 -0
- package/src/functions.ts +153 -0
- package/src/index.ts +65 -0
- package/src/is.ts +195 -0
- package/src/match.ts +78 -0
- package/src/objects.ts +180 -0
package/src/async.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Async utility functions — retry, sleep, parallel, timeout, debounce, throttle.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```ts
|
|
6
|
+
* await sleep('2s')
|
|
7
|
+
* const data = await retry(3, () => fetchApi(), '500ms')
|
|
8
|
+
* const results = await parallel([fn1, fn2, fn3], { concurrency: 2 })
|
|
9
|
+
* const result = await timeout(fetchData(), '5s')
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Parse a human-readable duration string to milliseconds */
|
|
14
|
+
export function parseDuration(duration: string | number): number {
|
|
15
|
+
if (typeof duration === 'number') return duration
|
|
16
|
+
const match = duration.match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/i)
|
|
17
|
+
if (!match) throw new Error(`Invalid duration: ${duration}`)
|
|
18
|
+
const value = parseFloat(match[1]!)
|
|
19
|
+
const unit = (match[2] ?? 'ms').toLowerCase()
|
|
20
|
+
switch (unit) {
|
|
21
|
+
case 'ms': return value
|
|
22
|
+
case 's': return value * 1000
|
|
23
|
+
case 'm': return value * 60_000
|
|
24
|
+
case 'h': return value * 3_600_000
|
|
25
|
+
case 'd': return value * 86_400_000
|
|
26
|
+
default: return value
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Sleep for a given duration */
|
|
31
|
+
export function sleep(duration: string | number): Promise<void> {
|
|
32
|
+
const ms = parseDuration(duration)
|
|
33
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Retry a function up to N times with optional delay between attempts.
|
|
38
|
+
* Supports exponential backoff via callback delay.
|
|
39
|
+
*/
|
|
40
|
+
export async function retry<T>(
|
|
41
|
+
times: number,
|
|
42
|
+
callback: (attempt: number) => T | Promise<T>,
|
|
43
|
+
delay?: string | number | ((attempt: number) => string | number),
|
|
44
|
+
): Promise<T> {
|
|
45
|
+
let lastError: Error | undefined
|
|
46
|
+
for (let attempt = 1; attempt <= times; attempt++) {
|
|
47
|
+
try {
|
|
48
|
+
return await callback(attempt)
|
|
49
|
+
} catch (e) {
|
|
50
|
+
lastError = e instanceof Error ? e : new Error(String(e))
|
|
51
|
+
if (attempt < times && delay !== undefined) {
|
|
52
|
+
const d = typeof delay === 'function' ? delay(attempt) : delay
|
|
53
|
+
await sleep(d)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
throw lastError
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Run multiple async functions with optional concurrency limit.
|
|
62
|
+
* Like Promise.all but with a concurrency pool.
|
|
63
|
+
*/
|
|
64
|
+
export async function parallel<T>(
|
|
65
|
+
tasks: Array<() => Promise<T>>,
|
|
66
|
+
options?: { concurrency?: number },
|
|
67
|
+
): Promise<T[]> {
|
|
68
|
+
const concurrency = options?.concurrency ?? Infinity
|
|
69
|
+
|
|
70
|
+
if (concurrency >= tasks.length) {
|
|
71
|
+
return Promise.all(tasks.map((fn) => fn()))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const results: T[] = new Array(tasks.length)
|
|
75
|
+
let nextIndex = 0
|
|
76
|
+
|
|
77
|
+
async function runNext(): Promise<void> {
|
|
78
|
+
while (nextIndex < tasks.length) {
|
|
79
|
+
const idx = nextIndex++
|
|
80
|
+
results[idx] = await tasks[idx]!()
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const workers = Array.from(
|
|
85
|
+
{ length: Math.min(concurrency, tasks.length) },
|
|
86
|
+
() => runNext(),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
await Promise.all(workers)
|
|
90
|
+
return results
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Wrap a promise with a timeout — rejects if it doesn't resolve in time */
|
|
94
|
+
export function timeout<T>(
|
|
95
|
+
promise: Promise<T>,
|
|
96
|
+
duration: string | number,
|
|
97
|
+
message?: string,
|
|
98
|
+
): Promise<T> {
|
|
99
|
+
const ms = parseDuration(duration)
|
|
100
|
+
return new Promise<T>((resolve, reject) => {
|
|
101
|
+
const timer = setTimeout(() => {
|
|
102
|
+
reject(new Error(message ?? `Operation timed out after ${ms}ms`))
|
|
103
|
+
}, ms)
|
|
104
|
+
|
|
105
|
+
promise.then(
|
|
106
|
+
(value) => { clearTimeout(timer); resolve(value) },
|
|
107
|
+
(error) => { clearTimeout(timer); reject(error) },
|
|
108
|
+
)
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Create a debounced version of a function.
|
|
114
|
+
* The function will only execute after `wait` ms of no calls.
|
|
115
|
+
*/
|
|
116
|
+
export function debounce<T extends (...args: any[]) => any>(
|
|
117
|
+
fn: T,
|
|
118
|
+
wait: string | number,
|
|
119
|
+
): T & { cancel(): void; flush(): void } {
|
|
120
|
+
const ms = parseDuration(wait)
|
|
121
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
122
|
+
let lastArgs: any[] | null = null
|
|
123
|
+
let lastThis: any = null
|
|
124
|
+
|
|
125
|
+
const debounced = function (this: any, ...args: any[]) {
|
|
126
|
+
lastArgs = args
|
|
127
|
+
lastThis = this
|
|
128
|
+
if (timer) clearTimeout(timer)
|
|
129
|
+
timer = setTimeout(() => {
|
|
130
|
+
timer = null
|
|
131
|
+
fn.apply(lastThis, lastArgs!)
|
|
132
|
+
lastArgs = null
|
|
133
|
+
}, ms)
|
|
134
|
+
} as any
|
|
135
|
+
|
|
136
|
+
debounced.cancel = () => {
|
|
137
|
+
if (timer) clearTimeout(timer)
|
|
138
|
+
timer = null
|
|
139
|
+
lastArgs = null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
debounced.flush = () => {
|
|
143
|
+
if (timer && lastArgs) {
|
|
144
|
+
clearTimeout(timer)
|
|
145
|
+
timer = null
|
|
146
|
+
fn.apply(lastThis, lastArgs)
|
|
147
|
+
lastArgs = null
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return debounced
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Create a throttled version of a function.
|
|
156
|
+
* The function will execute at most once per `wait` ms.
|
|
157
|
+
*/
|
|
158
|
+
export function throttle<T extends (...args: any[]) => any>(
|
|
159
|
+
fn: T,
|
|
160
|
+
wait: string | number,
|
|
161
|
+
): T & { cancel(): void } {
|
|
162
|
+
const ms = parseDuration(wait)
|
|
163
|
+
let lastCall = 0
|
|
164
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
165
|
+
|
|
166
|
+
const throttled = function (this: any, ...args: any[]) {
|
|
167
|
+
const now = Date.now()
|
|
168
|
+
const remaining = ms - (now - lastCall)
|
|
169
|
+
|
|
170
|
+
if (remaining <= 0) {
|
|
171
|
+
if (timer) { clearTimeout(timer); timer = null }
|
|
172
|
+
lastCall = now
|
|
173
|
+
fn.apply(this, args)
|
|
174
|
+
} else if (!timer) {
|
|
175
|
+
timer = setTimeout(() => {
|
|
176
|
+
lastCall = Date.now()
|
|
177
|
+
timer = null
|
|
178
|
+
fn.apply(this, args)
|
|
179
|
+
}, remaining)
|
|
180
|
+
}
|
|
181
|
+
} as any
|
|
182
|
+
|
|
183
|
+
throttled.cancel = () => {
|
|
184
|
+
if (timer) clearTimeout(timer)
|
|
185
|
+
timer = null
|
|
186
|
+
lastCall = 0
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return throttled
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Run tasks in a waterfall: each task receives the result of the previous.
|
|
194
|
+
*/
|
|
195
|
+
export async function waterfall<T>(
|
|
196
|
+
initial: T,
|
|
197
|
+
tasks: Array<(value: T) => T | Promise<T>>,
|
|
198
|
+
): Promise<T> {
|
|
199
|
+
let result = initial
|
|
200
|
+
for (const task of tasks) {
|
|
201
|
+
result = await task(result)
|
|
202
|
+
}
|
|
203
|
+
return result
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Defer a function to the next microtask */
|
|
207
|
+
export function defer(fn: () => void): void {
|
|
208
|
+
queueMicrotask(fn)
|
|
209
|
+
}
|
package/src/functions.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Function utilities — tap, pipe, pipeline, once, memoize, benchmark.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```ts
|
|
6
|
+
* const user = tap(new User(), (u) => { u.name = 'John' })
|
|
7
|
+
* const result = pipe(5, double, addOne, toString) // '11'
|
|
8
|
+
* const getValue = once(() => expensiveComputation())
|
|
9
|
+
* const cachedFetch = memoize(fetchUser, { ttl: 60_000 })
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { parseDuration } from './async.ts'
|
|
14
|
+
|
|
15
|
+
/** Pass a value through a callback and return the original value */
|
|
16
|
+
export function tap<T>(value: T, callback: (value: T) => void): T {
|
|
17
|
+
callback(value)
|
|
18
|
+
return value
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Pipe a value through a sequence of functions (left to right).
|
|
23
|
+
* Each function receives the return value of the previous.
|
|
24
|
+
*/
|
|
25
|
+
export function pipe<A, B>(value: A, fn1: (a: A) => B): B
|
|
26
|
+
export function pipe<A, B, C>(value: A, fn1: (a: A) => B, fn2: (b: B) => C): C
|
|
27
|
+
export function pipe<A, B, C, D>(value: A, fn1: (a: A) => B, fn2: (b: B) => C, fn3: (c: C) => D): D
|
|
28
|
+
export function pipe<A, B, C, D, E>(value: A, fn1: (a: A) => B, fn2: (b: B) => C, fn3: (c: C) => D, fn4: (d: D) => E): E
|
|
29
|
+
export function pipe(value: any, ...fns: Array<(arg: any) => any>): any {
|
|
30
|
+
return fns.reduce((acc, fn) => fn(acc), value)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Pipeline: pass a value through an array of functions.
|
|
35
|
+
* Same as pipe but takes an array instead of variadic args.
|
|
36
|
+
*/
|
|
37
|
+
export function pipeline<T>(value: T, fns: Array<(value: any) => any>): any {
|
|
38
|
+
return fns.reduce((acc, fn) => fn(acc), value as any)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Compose functions right to left */
|
|
42
|
+
export function compose<T>(...fns: Array<(arg: any) => any>): (arg: T) => any {
|
|
43
|
+
return (arg: T) => fns.reduceRight((acc, fn) => fn(acc), arg as any)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Create a function that executes only once.
|
|
48
|
+
* Subsequent calls return the first result.
|
|
49
|
+
*/
|
|
50
|
+
export function once<T extends (...args: any[]) => any>(fn: T): T {
|
|
51
|
+
let called = false
|
|
52
|
+
let result: any
|
|
53
|
+
|
|
54
|
+
return ((...args: any[]) => {
|
|
55
|
+
if (called) return result
|
|
56
|
+
called = true
|
|
57
|
+
result = fn(...args)
|
|
58
|
+
return result
|
|
59
|
+
}) as T
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Memoize a function with optional TTL.
|
|
64
|
+
* Cache key is derived from arguments (JSON serialized).
|
|
65
|
+
*/
|
|
66
|
+
export function memoize<T extends (...args: any[]) => any>(
|
|
67
|
+
fn: T,
|
|
68
|
+
options?: { ttl?: string | number; maxSize?: number },
|
|
69
|
+
): T & { cache: Map<string, any>; clear(): void } {
|
|
70
|
+
const ttl = options?.ttl ? parseDuration(options.ttl) : null
|
|
71
|
+
const maxSize = options?.maxSize ?? 1000
|
|
72
|
+
const cache = new Map<string, { value: any; expiresAt: number | null }>()
|
|
73
|
+
|
|
74
|
+
const memoized = ((...args: any[]) => {
|
|
75
|
+
const key = JSON.stringify(args)
|
|
76
|
+
const entry = cache.get(key)
|
|
77
|
+
|
|
78
|
+
if (entry) {
|
|
79
|
+
if (entry.expiresAt === null || Date.now() < entry.expiresAt) {
|
|
80
|
+
return entry.value
|
|
81
|
+
}
|
|
82
|
+
cache.delete(key)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const result = fn(...args)
|
|
86
|
+
const expiresAt = ttl ? Date.now() + ttl : null
|
|
87
|
+
|
|
88
|
+
// Evict oldest if at capacity
|
|
89
|
+
if (cache.size >= maxSize) {
|
|
90
|
+
const firstKey = cache.keys().next().value
|
|
91
|
+
if (firstKey !== undefined) cache.delete(firstKey)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
cache.set(key, { value: result, expiresAt })
|
|
95
|
+
return result
|
|
96
|
+
}) as any
|
|
97
|
+
|
|
98
|
+
memoized.cache = cache
|
|
99
|
+
memoized.clear = () => cache.clear()
|
|
100
|
+
|
|
101
|
+
return memoized
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Benchmark a function's execution time.
|
|
106
|
+
* Returns [result, durationMs].
|
|
107
|
+
*/
|
|
108
|
+
export async function benchmark<T>(
|
|
109
|
+
fn: () => T | Promise<T>,
|
|
110
|
+
): Promise<[T, number]> {
|
|
111
|
+
const start = performance.now()
|
|
112
|
+
const result = await fn()
|
|
113
|
+
const duration = performance.now() - start
|
|
114
|
+
return [result, duration]
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** No-op function */
|
|
118
|
+
export function noop(): void {}
|
|
119
|
+
|
|
120
|
+
/** Identity function — returns its argument unchanged */
|
|
121
|
+
export function identity<T>(value: T): T {
|
|
122
|
+
return value
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Create a function that always returns the same value.
|
|
127
|
+
*/
|
|
128
|
+
export function constant<T>(value: T): () => T {
|
|
129
|
+
return () => value
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Wrap a value in a callback — useful for lazy evaluation.
|
|
134
|
+
*/
|
|
135
|
+
export function lazy<T>(fn: () => T): { value: T } {
|
|
136
|
+
let computed = false
|
|
137
|
+
let result: T
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
get value(): T {
|
|
141
|
+
if (!computed) {
|
|
142
|
+
result = fn()
|
|
143
|
+
computed = true
|
|
144
|
+
}
|
|
145
|
+
return result
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Times: execute a callback N times, collect results */
|
|
151
|
+
export function times<T>(n: number, fn: (index: number) => T): T[] {
|
|
152
|
+
return Array.from({ length: n }, (_, i) => fn(i))
|
|
153
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// @mantiq/helpers — public API exports
|
|
2
|
+
|
|
3
|
+
// ── Laravel-equivalent utilities ─────────────────────────────────
|
|
4
|
+
export { Str, Stringable } from './Str.ts'
|
|
5
|
+
export { Arr } from './Arr.ts'
|
|
6
|
+
export { Num } from './Num.ts'
|
|
7
|
+
export { Collection, LazyCollection, collect, lazy, generate, range } from './Collection.ts'
|
|
8
|
+
|
|
9
|
+
// ── Beyond-Laravel utilities ─────────────────────────────────────
|
|
10
|
+
export { Result } from './Result.ts'
|
|
11
|
+
export type { Result as ResultType } from './Result.ts'
|
|
12
|
+
export { match } from './match.ts'
|
|
13
|
+
export { Duration } from './Duration.ts'
|
|
14
|
+
export { is } from './is.ts'
|
|
15
|
+
|
|
16
|
+
// ── HTTP client ──────────────────────────────────────────────────
|
|
17
|
+
export { Http, PendingRequest } from './Http.ts'
|
|
18
|
+
export type { HttpResponse, HttpError, HttpMiddleware, RetryConfig } from './Http.ts'
|
|
19
|
+
export { HttpFake } from './HttpFake.ts'
|
|
20
|
+
export type { StubResponse, StubHandler } from './HttpFake.ts'
|
|
21
|
+
|
|
22
|
+
// ── Async utilities ──────────────────────────────────────────────
|
|
23
|
+
export {
|
|
24
|
+
parseDuration,
|
|
25
|
+
sleep,
|
|
26
|
+
retry,
|
|
27
|
+
parallel,
|
|
28
|
+
timeout,
|
|
29
|
+
debounce,
|
|
30
|
+
throttle,
|
|
31
|
+
waterfall,
|
|
32
|
+
defer,
|
|
33
|
+
} from './async.ts'
|
|
34
|
+
|
|
35
|
+
// ── Object utilities ─────────────────────────────────────────────
|
|
36
|
+
export {
|
|
37
|
+
deepClone,
|
|
38
|
+
deepMerge,
|
|
39
|
+
deepFreeze,
|
|
40
|
+
deepEqual,
|
|
41
|
+
pick,
|
|
42
|
+
omit,
|
|
43
|
+
diff,
|
|
44
|
+
mapValues,
|
|
45
|
+
mapKeys,
|
|
46
|
+
filterObject,
|
|
47
|
+
invert,
|
|
48
|
+
isPlainObject,
|
|
49
|
+
} from './objects.ts'
|
|
50
|
+
|
|
51
|
+
// ── Function utilities ───────────────────────────────────────────
|
|
52
|
+
export {
|
|
53
|
+
tap,
|
|
54
|
+
pipe,
|
|
55
|
+
pipeline,
|
|
56
|
+
compose,
|
|
57
|
+
once,
|
|
58
|
+
memoize,
|
|
59
|
+
benchmark,
|
|
60
|
+
noop,
|
|
61
|
+
identity,
|
|
62
|
+
constant,
|
|
63
|
+
lazy as lazyValue,
|
|
64
|
+
times,
|
|
65
|
+
} from './functions.ts'
|
package/src/is.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime type guards and predicates.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```ts
|
|
6
|
+
* is.string('hello') // true
|
|
7
|
+
* is.empty([]) // true
|
|
8
|
+
* is.plainObject({}) // true
|
|
9
|
+
* is.between(5, 1, 10) // true
|
|
10
|
+
* is.email('user@example.com') // true
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
export const is = {
|
|
14
|
+
// ── Type checks ─────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
string(value: unknown): value is string {
|
|
17
|
+
return typeof value === 'string'
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
number(value: unknown): value is number {
|
|
21
|
+
return typeof value === 'number' && !Number.isNaN(value)
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
integer(value: unknown): value is number {
|
|
25
|
+
return typeof value === 'number' && Number.isInteger(value)
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
float(value: unknown): value is number {
|
|
29
|
+
return typeof value === 'number' && !Number.isInteger(value) && !Number.isNaN(value)
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
boolean(value: unknown): value is boolean {
|
|
33
|
+
return typeof value === 'boolean'
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
function(value: unknown): value is Function {
|
|
37
|
+
return typeof value === 'function'
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
symbol(value: unknown): value is symbol {
|
|
41
|
+
return typeof value === 'symbol'
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
bigint(value: unknown): value is bigint {
|
|
45
|
+
return typeof value === 'bigint'
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
array(value: unknown): value is any[] {
|
|
49
|
+
return Array.isArray(value)
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
object(value: unknown): value is object {
|
|
53
|
+
return value !== null && typeof value === 'object'
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
plainObject(value: unknown): value is Record<string, any> {
|
|
57
|
+
if (value === null || typeof value !== 'object') return false
|
|
58
|
+
const proto = Object.getPrototypeOf(value)
|
|
59
|
+
return proto === Object.prototype || proto === null
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
date(value: unknown): value is Date {
|
|
63
|
+
return value instanceof Date && !isNaN(value.getTime())
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
regExp(value: unknown): value is RegExp {
|
|
67
|
+
return value instanceof RegExp
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
promise(value: unknown): value is Promise<any> {
|
|
71
|
+
return value instanceof Promise || (
|
|
72
|
+
value !== null && typeof value === 'object' && typeof (value as any).then === 'function'
|
|
73
|
+
)
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
map(value: unknown): value is Map<any, any> {
|
|
77
|
+
return value instanceof Map
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
set(value: unknown): value is Set<any> {
|
|
81
|
+
return value instanceof Set
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
error(value: unknown): value is Error {
|
|
85
|
+
return value instanceof Error
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
// ── Nullish checks ──────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
null(value: unknown): value is null {
|
|
91
|
+
return value === null
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
undefined(value: unknown): value is undefined {
|
|
95
|
+
return value === undefined
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
nullish(value: unknown): value is null | undefined {
|
|
99
|
+
return value === null || value === undefined
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
defined<T>(value: T | null | undefined): value is T {
|
|
103
|
+
return value !== null && value !== undefined
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
// ── Emptiness ───────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
/** Check if a value is "empty" (null, undefined, '', [], {}, Map(0), Set(0)) */
|
|
109
|
+
empty(value: unknown): boolean {
|
|
110
|
+
if (value === null || value === undefined) return true
|
|
111
|
+
if (typeof value === 'string') return value.length === 0
|
|
112
|
+
if (Array.isArray(value)) return value.length === 0
|
|
113
|
+
if (value instanceof Map || value instanceof Set) return value.size === 0
|
|
114
|
+
if (typeof value === 'object') return Object.keys(value).length === 0
|
|
115
|
+
return false
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
/** Opposite of empty */
|
|
119
|
+
notEmpty(value: unknown): boolean {
|
|
120
|
+
return !is.empty(value)
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
// ── String format checks ────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
email(value: string): boolean {
|
|
126
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
url(value: string): boolean {
|
|
130
|
+
try { new URL(value); return true } catch { return false }
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
uuid(value: string): boolean {
|
|
134
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
json(value: string): boolean {
|
|
138
|
+
try { JSON.parse(value); return true } catch { return false }
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
numeric(value: string): boolean {
|
|
142
|
+
return /^-?\d+(\.\d+)?$/.test(value)
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
alpha(value: string): boolean {
|
|
146
|
+
return /^[a-zA-Z]+$/.test(value)
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
alphanumeric(value: string): boolean {
|
|
150
|
+
return /^[a-zA-Z0-9]+$/.test(value)
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
ip(value: string): boolean {
|
|
154
|
+
// IPv4
|
|
155
|
+
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(value)) {
|
|
156
|
+
return value.split('.').every((n) => parseInt(n, 10) <= 255)
|
|
157
|
+
}
|
|
158
|
+
// IPv6 (simplified check)
|
|
159
|
+
return /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/.test(value)
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
// ── Number checks ───────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
positive(value: number): boolean {
|
|
165
|
+
return value > 0
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
negative(value: number): boolean {
|
|
169
|
+
return value < 0
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
zero(value: number): boolean {
|
|
173
|
+
return value === 0
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
between(value: number, min: number, max: number): boolean {
|
|
177
|
+
return value >= min && value <= max
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
even(value: number): boolean {
|
|
181
|
+
return value % 2 === 0
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
odd(value: number): boolean {
|
|
185
|
+
return value % 2 !== 0
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
finite(value: number): boolean {
|
|
189
|
+
return Number.isFinite(value)
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
nan(value: unknown): boolean {
|
|
193
|
+
return Number.isNaN(value)
|
|
194
|
+
},
|
|
195
|
+
}
|
package/src/match.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Functional pattern matching — an expressive alternative to switch/if-else chains.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```ts
|
|
6
|
+
* const label = match(statusCode)
|
|
7
|
+
* .when(200, 'OK')
|
|
8
|
+
* .when(404, 'Not Found')
|
|
9
|
+
* .when((code) => code >= 500, 'Server Error')
|
|
10
|
+
* .otherwise('Unknown')
|
|
11
|
+
*
|
|
12
|
+
* const result = match(user.role)
|
|
13
|
+
* .when('admin', () => getAdminDashboard())
|
|
14
|
+
* .when('editor', () => getEditorPanel())
|
|
15
|
+
* .when(['viewer', 'guest'], () => getPublicView())
|
|
16
|
+
* .otherwise(() => redirect('/login'))
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
type MatchPredicate<T> = T | T[] | ((value: T) => boolean)
|
|
21
|
+
type MatchResult<R> = R | (() => R)
|
|
22
|
+
|
|
23
|
+
interface MatchBuilder<T, R = never> {
|
|
24
|
+
/** Match against a value, array of values, or predicate function */
|
|
25
|
+
when<U>(predicate: MatchPredicate<T>, result: MatchResult<U>): MatchBuilder<T, R | U>
|
|
26
|
+
/** Provide a default value if nothing matched */
|
|
27
|
+
otherwise<U>(result: MatchResult<U>): R | U
|
|
28
|
+
/** Execute without a default — throws if nothing matched */
|
|
29
|
+
exhaustive(): R
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
class MatchImpl<T, R> implements MatchBuilder<T, R> {
|
|
33
|
+
private arms: Array<{ predicate: MatchPredicate<T>; result: MatchResult<any> }> = []
|
|
34
|
+
private matched = false
|
|
35
|
+
private matchedResult: any = undefined
|
|
36
|
+
|
|
37
|
+
constructor(private readonly value: T) {}
|
|
38
|
+
|
|
39
|
+
when<U>(predicate: MatchPredicate<T>, result: MatchResult<U>): MatchBuilder<T, R | U> {
|
|
40
|
+
if (this.matched) return this as any
|
|
41
|
+
|
|
42
|
+
if (this.test(predicate)) {
|
|
43
|
+
this.matched = true
|
|
44
|
+
this.matchedResult = typeof result === 'function' ? (result as () => U)() : result
|
|
45
|
+
} else {
|
|
46
|
+
this.arms.push({ predicate, result })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return this as any
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
otherwise<U>(result: MatchResult<U>): R | U {
|
|
53
|
+
if (this.matched) return this.matchedResult
|
|
54
|
+
return typeof result === 'function' ? (result as () => U)() : result
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
exhaustive(): R {
|
|
58
|
+
if (this.matched) return this.matchedResult
|
|
59
|
+
throw new Error(`No match found for value: ${JSON.stringify(this.value)}`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private test(predicate: MatchPredicate<T>): boolean {
|
|
63
|
+
if (typeof predicate === 'function') {
|
|
64
|
+
return (predicate as (value: T) => boolean)(this.value)
|
|
65
|
+
}
|
|
66
|
+
if (Array.isArray(predicate)) {
|
|
67
|
+
return predicate.includes(this.value)
|
|
68
|
+
}
|
|
69
|
+
return Object.is(this.value, predicate)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Start a pattern match on a value.
|
|
75
|
+
*/
|
|
76
|
+
export function match<T>(value: T): MatchBuilder<T> {
|
|
77
|
+
return new MatchImpl(value)
|
|
78
|
+
}
|