@typed/guard 0.6.0 → 0.7.0

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 ADDED
@@ -0,0 +1,197 @@
1
+ # @typed/guard
2
+
3
+ [![npm version](https://badge.fury.io/js/%40typed%2Fguard.svg)](https://badge.fury.io/js/%40typed%2Fguard)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ A powerful, type-safe runtime validation library built on top of [Effect](https://effect.website/). `@typed/guard` provides a flexible and composable way to validate and transform data with full type inference and runtime safety.
7
+
8
+ ## Features
9
+
10
+ - 🛡️ **Type-safe Guards**: Runtime validation with complete type inference
11
+ - 🔄 **Effect Integration**: Seamless integration with Effect's ecosystem
12
+ - 🎯 **Composable**: Rich set of combinators for building complex validations
13
+ - 🌳 **Tree-shakeable**: Only import what you need
14
+ - 🔍 **Schema Integration**: First-class support for Effect's Schema
15
+ - ⚡ **Zero Dependencies**: Only requires Effect as a peer dependency
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ # Using npm
21
+ npm install @typed/guard effect
22
+
23
+ # Using yarn
24
+ yarn add @typed/guard effect
25
+
26
+ # Using pnpm
27
+ pnpm add @typed/guard effect
28
+ ```
29
+
30
+ ## Quick Start
31
+
32
+ ```typescript
33
+ import * as Guard from '@typed/guard'
34
+ import { Effect, Option } from 'effect'
35
+
36
+ // Simple number validation
37
+ const isPositive = Guard.liftPredicate((n: number) => n > 0)
38
+
39
+ // Usage
40
+ const result = Effect.runSync(isPositive(5)) // Option.some(5)
41
+ const invalid = Effect.runSync(isPositive(-1)) // Option.none()
42
+
43
+ // Composing guards
44
+ const toString = Guard.map(isPositive, n => n.toString())
45
+ const result2 = Effect.runSync(toString(5)) // Option.some("5")
46
+ ```
47
+
48
+ ## Core Concepts
49
+
50
+ ### Guards
51
+
52
+ A Guard is a function that takes an input and returns an `Effect` containing an `Option` of the output:
53
+
54
+ ```typescript
55
+ type Guard<I, O = never, E = never, R = never> =
56
+ (input: I) => Effect.Effect<Option.Option<O>, E, R>
57
+ ```
58
+
59
+ - `I`: Input type
60
+ - `O`: Output type
61
+ - `E`: Error type
62
+ - `R`: Required context
63
+
64
+ ### Key Operations
65
+
66
+ #### Composition
67
+
68
+ ```typescript
69
+ const numberToString = (n: number) =>
70
+ Effect.succeed(n > 5 ? Option.some(n.toString()) : Option.none())
71
+
72
+ const stringToLength = (s: string) =>
73
+ Effect.succeed(s.length > 1 ? Option.some(s.length) : Option.none())
74
+
75
+ // Compose them together
76
+ const composed: Guard<number, number> = Guard.compose(isPositive, stringLength)
77
+ ```
78
+
79
+ #### Filtering and Mapping
80
+
81
+ ```typescript
82
+ // Filter values
83
+ const evenNumbers = Guard.filter(isPositive, n => n % 2 === 0)
84
+
85
+ // Map values with effects
86
+ const asyncTransform = Guard.mapEffect(isPositive, n =>
87
+ Effect.promise(() => Promise.resolve(n * 2))
88
+ )
89
+ ```
90
+
91
+ #### Error Handling
92
+
93
+ ```typescript
94
+ const withRecovery = Guard.catchAll(
95
+ failingGuard,
96
+ error => Effect.succeed(`Recovered from: ${error}`)
97
+ )
98
+
99
+ const withTaggedRecovery = Guard.catchTag(
100
+ failingGuard,
101
+ 'ValidationError',
102
+ error => Effect.succeed(`Invalid input: ${error.message}`)
103
+ )
104
+ ```
105
+
106
+ #### Schema Integration
107
+
108
+ ```typescript
109
+ import { Schema } from 'effect'
110
+
111
+ const PersonSchema = Schema.struct({
112
+ name: Schema.string,
113
+ age: Schema.number
114
+ })
115
+
116
+ const personGuard = Guard.fromSchemaDecode(PersonSchema)
117
+ ```
118
+
119
+ ## Advanced Usage
120
+
121
+ ### Pattern Matching with `any`
122
+
123
+ ```typescript
124
+ const numberOrString = Guard.any({
125
+ number: Guard.liftPredicate((n): n is number => typeof n === 'number'),
126
+ string: Guard.liftPredicate((s): s is string => typeof s === 'string')
127
+ })
128
+
129
+ // Returns: { _tag: 'number', value: 123 }
130
+ Effect.runSync(numberOrString(123))
131
+
132
+ // Returns: { _tag: 'string', value: 'hello' }
133
+ Effect.runSync(numberOrString('hello'))
134
+ ```
135
+
136
+ ### Property Binding
137
+
138
+ ```typescript
139
+ import { pipe } from 'effect'
140
+
141
+ const complexGuard = pipe(
142
+ Guard.identity<number>,
143
+ Guard.bindTo('value'),
144
+ Guard.bind('asString', ({ value }) =>
145
+ Effect.succeedSome(value.toString())
146
+ ),
147
+ Guard.let('asBigInt', ({ asString }) =>
148
+ BigInt(asString)
149
+ )
150
+ )
151
+
152
+ // Returns: { value: 123, asString: '123', asBigInt: 123n }
153
+ Effect.runSync(complexGuard(123))
154
+ ```
155
+
156
+ ### Creating Reusable Guards with `Guardable`
157
+
158
+ The `Guardable` class allows you to create reusable guards as classes. This is particularly useful when you need to create guards that are also data structures
159
+
160
+ ```typescript
161
+ import { Guardable, GUARDABLE } from '@typed/guard'
162
+ import { Effect } from 'effect'
163
+
164
+ // Create a guard that multiplies numbers by a configurable value
165
+ class MultiplyGuard extends Guardable<number, number> {
166
+ constructor(readonly multiplier: number) {
167
+ super()
168
+ }
169
+
170
+ // Implement the GUARDABLE symbol to define the guard's behavior
171
+ [GUARDABLE] = (input: number) =>
172
+ Effect.succeedSome(input * this.multiplier)
173
+ }
174
+
175
+ // Create an instance with a specific multiplier
176
+ const multiplyByThree = new MultiplyGuard(3)
177
+
178
+ // Use it like a regular guard function
179
+ Effect.runSync(multiplyByThree(4)) // Option.some(12)
180
+
181
+ // It's pipeable too!
182
+
183
+ const guard = multiplyByThree.pipe(
184
+ Guard.map(n => n.toString()),
185
+ )
186
+
187
+ Effect.runSync(guard(4)) // Option.some("12")
188
+ ```
189
+
190
+ ## Contributing
191
+
192
+ Contributions are welcome! Please feel free to submit a Pull Request.
193
+
194
+ ## License
195
+
196
+ MIT © [Tylor Steinberger](https://github.com/TylorS)
197
+
@@ -0,0 +1,104 @@
1
+ import { describe, expect, it } from '@effect/vitest'
2
+ import { ExtensibleFunction } from './ExtensibleFunction.js'
3
+
4
+ describe('ExtensibleFunction', () => {
5
+ it('should create a function that can be called normally', () => {
6
+ const add = new ExtensibleFunction((x: number) => x + 1)
7
+ expect(add(1)).toBe(2)
8
+ })
9
+
10
+ it('should allow extending the function with additional properties', () => {
11
+ class EnhancedFunction extends ExtensibleFunction<(x: number) => number> {
12
+ description: string
13
+
14
+ constructor(fn: (x: number) => number, description: string) {
15
+ super(fn)
16
+ this.description = description
17
+ }
18
+ }
19
+
20
+ const add = new EnhancedFunction((x: number) => x + 1, 'adds one')
21
+ expect(add(1)).toBe(2)
22
+ expect(add.description).toBe('adds one')
23
+ })
24
+
25
+ it('should maintain function properties like length and name', () => {
26
+ const namedFn = function addOne(x: number) {
27
+ return x + 1
28
+ }
29
+ const add = new ExtensibleFunction(namedFn)
30
+ expect(add.length).toBe(1)
31
+ expect(add.name).toBe('addOne')
32
+ })
33
+
34
+ it('should work with async functions', async () => {
35
+ const asyncAdd = new ExtensibleFunction(async (x: number) => {
36
+ return x + 1
37
+ })
38
+ expect(await asyncAdd(1)).toBe(2)
39
+ })
40
+
41
+ it('should preserve this context in methods', () => {
42
+ class Calculator extends ExtensibleFunction<(x: number) => number> {
43
+ private multiplier: number
44
+
45
+ constructor(multiplier: number) {
46
+ super((x: number) => this.multiply(x))
47
+ this.multiplier = multiplier
48
+ }
49
+
50
+ private multiply(x: number): number {
51
+ return x * this.multiplier
52
+ }
53
+ }
54
+
55
+ const calc = new Calculator(2)
56
+ expect(calc(3)).toBe(6)
57
+ })
58
+
59
+ it('should handle multiple arguments correctly', () => {
60
+ const add = new ExtensibleFunction((a: number, b: number) => a + b)
61
+ expect(add(1, 2)).toBe(3)
62
+ })
63
+
64
+ it('should work with generic functions', () => {
65
+ const identity = new ExtensibleFunction(<T>(x: T) => x)
66
+ expect(identity(42)).toBe(42)
67
+ expect(identity('test')).toBe('test')
68
+ })
69
+
70
+ it('should throw when constructed without arguments', () => {
71
+ expect(() => new ExtensibleFunction(undefined as any)).toThrow()
72
+ })
73
+
74
+ it('should maintain proper error stack traces', () => {
75
+ class Throws<E> extends ExtensibleFunction<(cause: E) => never> {
76
+ constructor() {
77
+ super((cause) => {
78
+ throw cause
79
+ })
80
+ }
81
+ }
82
+
83
+ const throws = new Throws<Error>()
84
+
85
+ try {
86
+ throws(new Error('test error'))
87
+ } catch (e) {
88
+ expect(e instanceof Error).toBe(true)
89
+ expect(e.stack).toBeDefined()
90
+ expect(e.message).toBe('test error')
91
+ }
92
+ })
93
+
94
+ it('should allow function composition', () => {
95
+ const add1 = new ExtensibleFunction((x: number) => x + 1)
96
+ const multiply2 = new ExtensibleFunction((x: number) => x * 2)
97
+ const composed = new ExtensibleFunction((x: number) => multiply2(add1(x)))
98
+ expect(composed(3)).toBe(8) // (3 + 1) * 2
99
+ })
100
+
101
+ it('should allow class decorator', () => {
102
+
103
+ })
104
+ })
@@ -0,0 +1,8 @@
1
+ function Extensible<I, O>(f: (input: I) => O): (input: I) => O {
2
+ return Object.setPrototypeOf(f, new.target.prototype)
3
+ }
4
+ Extensible.prototype = Function.prototype
5
+
6
+ // Hack to make the type inference work
7
+ export const ExtensibleFunction: new <F extends (...inputs: readonly any[]) => any>(f: F) => F =
8
+ Extensible as any
@@ -0,0 +1,189 @@
1
+ import { Effect, identity, Option, pipe, Predicate, Schema } from 'effect'
2
+ import { describe, expect, it } from 'vitest'
3
+ import * as Guard from './Guard'
4
+
5
+ describe('Guard', () => {
6
+ // Helper function to run a guard and get the result
7
+ const runGuard = <I, O, E>(guard: Guard.Guard<I, O, E>, input: I) => Effect.runSync(guard(input))
8
+
9
+ describe('basic functionality', () => {
10
+ it('should create a basic guard that succeeds', () => {
11
+ const guard = (input: number): Effect.Effect<Option.Option<string>, never, never> =>
12
+ Effect.succeed(input > 5 ? Option.some(input.toString()) : Option.none())
13
+
14
+ expect(runGuard(guard, 10)).toEqual(Option.some('10'))
15
+ expect(runGuard(guard, 3)).toEqual(Option.none())
16
+ })
17
+ })
18
+
19
+ describe('compose', () => {
20
+ it('should compose two guards', () => {
21
+ const numberToString = (n: number) =>
22
+ Effect.succeed(n > 5 ? Option.some(n.toString()) : Option.none())
23
+ const stringToLength = (s: string) =>
24
+ Effect.succeed(s.length > 1 ? Option.some(s.length) : Option.none())
25
+
26
+ const composed = Guard.compose(numberToString, stringToLength)
27
+
28
+ expect(runGuard(composed, 10)).toEqual(Option.some(2))
29
+ expect(runGuard(composed, 3)).toEqual(Option.none())
30
+ })
31
+ })
32
+
33
+ describe('mapEffect and map', () => {
34
+ it('should map over guard results with Effect', () => {
35
+ const guard = (n: number) => Effect.succeed(n > 5 ? Option.some(n) : Option.none())
36
+ const mapped = Guard.mapEffect(guard, (n) => Effect.succeed(n * 2))
37
+
38
+ expect(runGuard(mapped, 10)).toEqual(Option.some(20))
39
+ expect(runGuard(mapped, 3)).toEqual(Option.none())
40
+ })
41
+
42
+ it('should map over guard results synchronously', () => {
43
+ const guard = (n: number) => Effect.succeed(n > 5 ? Option.some(n) : Option.none())
44
+ const mapped = Guard.map(guard, (n) => n * 2)
45
+
46
+ expect(runGuard(mapped, 10)).toEqual(Option.some(20))
47
+ expect(runGuard(mapped, 3)).toEqual(Option.none())
48
+ })
49
+ })
50
+
51
+ describe('tap', () => {
52
+ it('should perform side effects without modifying the result', () => {
53
+ let sideEffect = 0
54
+ const guard = (n: number) => Effect.succeed(n > 5 ? Option.some(n) : Option.none())
55
+ const tapped = Guard.tap(guard, (n) =>
56
+ Effect.sync(() => {
57
+ sideEffect = n
58
+ }),
59
+ )
60
+
61
+ expect(runGuard(tapped, 10)).toEqual(Option.some(10))
62
+ expect(sideEffect).toBe(10)
63
+
64
+ expect(runGuard(tapped, 3)).toEqual(Option.none())
65
+ expect(sideEffect).toBe(10) // Unchanged because guard returned None
66
+ })
67
+ })
68
+
69
+ describe('filterMap and filter', () => {
70
+ it('should filter and map values', () => {
71
+ const filtered = Guard.filterMap(Guard.identity<number>, (n) =>
72
+ n > 5 ? Option.some(n.toString()) : Option.none(),
73
+ )
74
+
75
+ expect(runGuard(filtered, 10)).toEqual(Option.some('10'))
76
+ expect(runGuard(filtered, 3)).toEqual(Option.none())
77
+ })
78
+
79
+ it('should filter values', () => {
80
+ const filtered = Guard.filter(Guard.identity<number>, (n) => n > 5)
81
+
82
+ expect(runGuard(filtered, 10)).toEqual(Option.some(10))
83
+ expect(runGuard(filtered, 3)).toEqual(Option.none())
84
+ })
85
+ })
86
+
87
+ describe('any', () => {
88
+ it('should match against multiple guards', () => {
89
+ const anyGuard = Guard.any({
90
+ number: Guard.liftPredicate(Predicate.isNumber),
91
+ string: Guard.liftPredicate(Predicate.isString),
92
+ })
93
+
94
+ expect(runGuard(anyGuard, 123)).toEqual(Option.some({ _tag: 'number', value: 123 }))
95
+ expect(runGuard(anyGuard, 'test')).toEqual(Option.some({ _tag: 'string', value: 'test' }))
96
+ expect(runGuard(anyGuard, true)).toEqual(Option.none())
97
+ })
98
+ })
99
+
100
+ describe('liftPredicate', () => {
101
+ it('should lift a predicate into a guard', () => {
102
+ const guard = Guard.liftPredicate(Predicate.isNumber)
103
+
104
+ expect(runGuard(guard, 123)).toEqual(Option.some(123))
105
+ expect(runGuard(guard, 'test')).toEqual(Option.none())
106
+ })
107
+ })
108
+
109
+ describe('error handling', () => {
110
+ describe('catchAll and catchAllCause', () => {
111
+ it('should catch errors', () => {
112
+ const failingGuard = (_: unknown) => Effect.fail('error')
113
+ const recovered = Guard.catchAll(failingGuard, (e) =>
114
+ Effect.succeed(['recovered:', e].join(' ')),
115
+ )
116
+
117
+ expect(runGuard(recovered, 123)).toEqual(Option.some('recovered: error'))
118
+ })
119
+
120
+ it('should catch error causes', () => {
121
+ const failingGuard = (_: unknown) => Effect.fail('error')
122
+ const recovered = Guard.catchAllCause(failingGuard, () =>
123
+ Effect.succeed('recovered from cause'),
124
+ )
125
+
126
+ expect(runGuard(recovered, 123)).toEqual(Option.some('recovered from cause'))
127
+ })
128
+ })
129
+
130
+ describe('catchTag', () => {
131
+ it('should catch specific error tags', () => {
132
+ type MyError = { _tag: 'MyError'; message: string }
133
+ const failingGuard = (_: unknown) =>
134
+ Effect.fail({ _tag: 'MyError', message: 'test error' } as MyError)
135
+ const recovered = Guard.catchTag(failingGuard, 'MyError', (e) =>
136
+ Effect.succeed(['recovered:', e.message].join(' ')),
137
+ )
138
+
139
+ expect(runGuard(recovered, 123)).toEqual(Option.some('recovered: test error'))
140
+ })
141
+ })
142
+ })
143
+
144
+ describe('schema integration', () => {
145
+ describe('fromSchemaDecode', () => {
146
+ it('should create a guard from a schema decoder', () => {
147
+ const guard = Guard.fromSchemaDecodeUnknown(Schema.Number)
148
+
149
+ expect(runGuard(guard, 123)).toEqual(Option.some(123))
150
+ expect(runGuard(guard, 'test')).toEqual(Option.none())
151
+ })
152
+ })
153
+
154
+ describe('fromSchemaEncode', () => {
155
+ it('should create a guard from a schema encoder', () => {
156
+ const guard = Guard.fromSchemaEncode(Schema.Number)
157
+
158
+ expect(runGuard(guard, 123)).toEqual(Option.some(123))
159
+ expect(runGuard(guard, 'test' as any)).toEqual(Option.none())
160
+ })
161
+ })
162
+ })
163
+
164
+ describe('property attachment', () => {
165
+ describe('attachProperty', () => {
166
+ it('should attach a property to the guarded value', () => {
167
+ const guard = (n: number) => Effect.succeed(Option.some({ value: n }))
168
+ const withProp = Guard.addTag(guard, 'number')
169
+
170
+ expect(runGuard(withProp, 123)).toEqual(Option.some({ _tag: 'number', value: 123 }))
171
+ })
172
+ })
173
+
174
+ describe('bind', () => {
175
+ it('should bind a new property based on the guarded value', () => {
176
+ const guard = pipe(
177
+ Guard.identity<number>,
178
+ Guard.bindTo('value'),
179
+ Guard.bind('asString', ({ value }) => Effect.succeedSome(value.toString())),
180
+ Guard.let('asBigInt', ({ asString }) => BigInt(asString)),
181
+ )
182
+
183
+ expect(runGuard(guard, 123)).toEqual(
184
+ Option.some({ value: 123, asString: '123', asBigInt: 123n }),
185
+ )
186
+ })
187
+ })
188
+ })
189
+ })