Skip to content

Commit 85ea440

Browse files
committed
Custom predicate tests, fail, README: Custom stuff.
1 parent b9a39d3 commit 85ea440

File tree

3 files changed

+210
-74
lines changed

3 files changed

+210
-74
lines changed

README.md

+177-65
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ Work in progress. Full docs pending. API breakage not out of question.
55
## What it is
66

77
This is a set of building blocks for type-safe JSON decoders, written in and for
8-
TypeScript. JSON decoders are well-known in languages like Elm or ReasonML
9-
, and their goal is, basically, to validate external data and guarantee it
10-
really is what the types say it is, so you can _safely_ rely on your types.
11-
12-
It’s functional in the right places, it’s compatible with a lot of imperative
13-
code, it’s flexible—it is, dare I say, pragmatic.
8+
TypeScript. JSON decoders are well-known in languages like Elm or ReasonML, and
9+
their goal is, basically, to validate external data and guarantee it really is
10+
what the types say it is, so you can _safely_ rely on your types.
11+
12+
It’s functional in the right places, it’s imperative-friendly, it’s flexible—it
13+
is, dare I say, pragmatic.
1414

1515
## Installation
1616

@@ -37,57 +37,60 @@ import { be } from 'be-good'
3737
const beString = be(isString)
3838
```
3939

40-
`be` takes a [user-defined type guard](https://www.typescriptlang.org/docs
41-
/handbook/advanced-types.html#user-defined-type-guards). E.g., lodash’s
42-
`isString` is typed as `(value?: any): value is string`, so TypeScript infers
43-
the `beString` return type as `string`.
40+
`be` takes a
41+
[user-defined type guard](https://www.typescriptlang.org/docs/handbook/advanced-types.html#user-defined-type-guards).
42+
E.g., lodash’s `isString` is typed as `(value: any): value is string`, so
43+
TypeScript infers the `beString` return type as `string`.
44+
45+
The functions that `be` returns (like `beSting`) are called decoders. A decoder
46+
is a function that takes an unknown value and returns a value of a proven type.
47+
48+
But, it is possible that a decoder cannot return a value of the necessary type,
49+
e.g., the input is invalid. In those cases, a decoder _throws_. Therefore, you might
50+
want to wrap all of your decoder invocations in `try/catch`, or...
51+
52+
### Or: catching decorators
53+
54+
The second base function is `or`: a factory for _catching decorators_. Sounds
55+
complicated, but it’s actually quite simple:
4456

45-
The functions that `be` returns (like `beSting`) are called decoders. A
46-
decoder is a function that takes an unknown value and returns a value of a proven type.
47-
48-
But, it is possible that a decoder cannot return a value of the necessary type
49-
, e.g., the input is invalid. In those cases, a decoder _throws_. So, you
50-
might want to wrap all of your decoder invocations in `try/catch`, or...
51-
52-
### Or: catching decorators
53-
54-
The second base function is `or`: a factory for _catching decorators_
55-
. Sounds complicated, but it’s actually quite simple:
56-
5757
```ts
5858
import { be, or } from 'be-good'
5959

60-
const optional = or(undefined) // a catching decorator
61-
const beString = be(isString) // + a decoder
60+
const optional = or(undefined) // a catching decorator
61+
const beString = be(isString) // + a decoder
6262
const beOptionalString = optional(beString) // = a decorated decoder
6363

64-
beOptionalString('Catchers in the Rye') // 'Catchers in the Rye`
65-
beOptionalString(-1) // undefined
64+
beOptionalString('Catchers in the Rye') // 'Catchers in the Rye`
65+
beOptionalString(-1) // undefined
6666
```
6767

6868
To describe what happens above:
6969

7070
- you apply `or` to a value (here, `undefined`) and get back a decorator
7171
- you apply the decorator to the decoder and get back a new decoder
7272
- if the new decoder is given a valid input value (here, a string), it returns
73-
that value
73+
that value
7474
- otherwise, it returns a fallback (`undefined`)
75-
76-
Obviously, the return type of `beOptionalString ` here is not just `string `, but `string | undefined`. On the other hand, nothing stops you from using a fallback of the same type as the expected value:
77-
78-
```ts
75+
76+
Obviously, the return type of `beOptionalString` here is not just `string`, but
77+
`string | undefined`. On the other hand, nothing stops you from using a fallback
78+
of the same type as the expected value:
79+
80+
```ts
7981
const alwaysBeNumber = or(0)(be(isNumber))
80-
```
82+
```
8183

82-
And sure, you can create one-off decorators on the fly. On the other hand
83-
, you may want to keep some of them (like the `optional` above) reusable.
84+
And sure, you can create one-off decorators on the fly. On the other hand, you
85+
may want to keep some of them (like the `optional` above) reusable across your
86+
app.
8487

8588
### Decoding objects
8689

8790
There’s a pretty low-level decoder called `beObject` that simply asserts that
88-
the value is indeed an object. It’s useful if you’re doing some
89-
non-standard stuff, like transforming your data instead of simply decoding
90-
–we’ll cover those scenarios later.
91+
the value is indeed an object. It’s useful if you’re doing some non-standard
92+
stuff, like transforming your data instead of simply decoding—we’ll cover those
93+
scenarios later.
9194

9295
For the most scenarios, there’s a more convenient decoder: `beObjectOf`.
9396

@@ -101,35 +104,42 @@ const beString = be(isString)
101104
const orNull = or(null)
102105

103106
type Mercenary = {
104-
name: string,
105-
fee: number,
106-
hasGun: boolean,
107+
name: string
108+
fee: number
109+
hasGun: boolean
107110
willTravel: boolean
108111
}
109112

110-
const mercenaryDecoder = orNull(beObjectOf<Mercenary>({
111-
name: beString,
112-
fee: beNumber,
113-
hasGun: beBoolean,
114-
willTravel: beBoolean
115-
}))
113+
const mercenaryDecoder = orNull(
114+
beObjectOf<Mercenary>({
115+
name: beString,
116+
fee: beNumber,
117+
hasGun: beBoolean,
118+
willTravel: beBoolean
119+
})
120+
)
116121
```
117122

118-
Never mind the silliness a mercenary without a gun that won’t travel (must
119-
be real good at sitting by the river waiting for those bodies), here’s
120-
how the decoder works.
121-
123+
Never mind the silliness a mercenary without a gun that won’t travel (must be
124+
real good at sitting by the river waiting for those bodies), here’s how the
125+
decoder works.
126+
122127
```ts
123128
mercenaryDecoder({ name: 'Al', fee: 100, hasGun: true, willTravel: true })
124129
// input is an object, has all the fields, hence the decoder returns a Mercenary
125130

126131
mercenaryDecoder({
127-
name: 'Will', fee: 50_000_000, hasGun: true, willTravel: 'No, Will Smith'
132+
name: 'Will',
133+
fee: 50_000_000,
134+
hasGun: true,
135+
willTravel: 'No, Will Smith'
128136
})
129137
// is object, right properties, wrong type, => null
130138

131139
mercenaryDecoder({
132-
name: 'Sara', hasGun: true, willTravel: true
140+
name: 'Sara',
141+
hasGun: true,
142+
willTravel: true
133143
})
134144
// is object, missing fields, => null
135145

@@ -152,27 +162,129 @@ mercenaryDecoder({
152162
### A note on generics and type inference
153163

154164
Actually, you don’t have to write `beObjectOf<Mercenary>`. The decoder return
155-
type will be inferred from the property decoders you gave to `beObjectOf`:
156-
e.g. `beObjectOf({ a: beString })` will have type `(x: unknown) => { a
157-
: string }`. And since TypeScript types are structural, it doesn’t matter
158-
how the type is called as long as the shape is right.
159-
160-
On the other hand, if you make a mistake in a property name or decoder you
161-
give to `beObjectOf`, TypeScript will fail—somewhere—but the error message
162-
might point to a place far from where the actual error is, and you’ll spend more time fixing it. So I’d recommend specifying the expected type right inside a decoder (like above), or maybe right outside of it, like this:
163-
164-
```ts
165+
type will be inferred from the property decoders you gave to `beObjectOf`: e.g.
166+
`beObjectOf({ a: beString })` will have type `(x: unknown) => { a : string }`.
167+
And since TypeScript types are structural, it doesn’t matter how the type is
168+
called as long as the shape is right.
169+
170+
Then again, if you make a mistake in a property name or decoder you give to
171+
`beObjectOf`, TypeScript will fail—somewhere—and the error message might point
172+
to a place far from where the actual error is, and you’ll spend more time fixing
173+
it. Better specify the expected type right inside a decoder (like above), or
174+
maybe right outside of it, like this:
175+
176+
```ts
165177
import { beOjbectOf, Decoder } from 'be-good'
166178
// ...
167179
const objDecoder: Decoder<Type> = optional(beObjectOf(/* ... */))
168-
```
180+
```
181+
182+
Fail early, I say.
183+
184+
## Collections: beArrayOf & beDictOf
185+
186+
`beArrayOf` and `beDictOf` are similar to `beObjectOf`, but their parameters
187+
are a bit different. First, they take a single element decoder—meaning all
188+
the elements are supposed to be of the same type. Second, the fabric has
189+
some other options:
190+
191+
```ts
192+
type BeCollectionOptions = {
193+
/** What to invalidate on errors */
194+
invalidate?: 'single' | 'all'
195+
/** Minimum count of (valid) collection elements */
196+
minSize?: number
197+
}
198+
```
199+
200+
Some examples:
201+
202+
```ts
203+
const beNumber = be(isNumber)
204+
205+
beArrayOf(beNumber)([3, 25.4, false, -7])
206+
// [3, 25.4, -7], because by default, `invalidate` option is 'singe',
207+
// and that means simply omitting invalid elements
208+
209+
beArrayOf(beNumber, { invalidate: 'all' })([3, 25.4, false, -7])
210+
// throws on the first bad element (use `or(...)`)
211+
212+
const orFallback = or('<fallback>')
213+
beArrayOf(orFallback(beNumber))([3, 25.4, false, -7])
214+
// [3, 25.4, '<fallback>', -7], compare to the first example
215+
216+
beArrayOf(beNumber, { minSize: 4 })([3, 25.4, false, -7])
217+
// throws: only 3 valid elements
218+
219+
/* beDictOf is about the same: */
220+
221+
beDictOf(beNumber)({ a: 3, b: 25.4, c: false, d: -7 })
222+
// { a: 3, b: 25.4, d: -7 }
223+
// etc...
224+
```
225+
226+
## Custom predicates and custom decoders
227+
228+
The type guard functions from lodash is handy, but what if you want to check for
229+
more?
230+
231+
```ts
232+
const isEven = (n: unknown): n is number => isNumber(n) && n % 2 === 0;
233+
234+
const beEven = be(isEven) // unknown => number
235+
```
236+
237+
If you dig opaque types, you can use them too (the enum trick taken from
238+
[an article by Patrick Bacon](https://spin.atomicobject.com/2017/06/19/strongly-typed-date-string-typescript/)).
239+
240+
```ts
241+
enum PriceBrand {}
242+
type Price = number & PriceBrand
243+
const isPrice = (n: unknown): n is Price => isNumber(n) && n > 0
244+
const bePrice = be(isPrice) // unknown => price
245+
```
246+
247+
You can also write whole custom decoders, be it because you cannot validate
248+
fields separately, or because you need to ~~mess with~~ manipulate your data
249+
structure:
250+
251+
```ts
252+
import { be, beObject, fail, or } from 'be-good'
253+
254+
type Range = {
255+
min: number;
256+
max: number;
257+
}
258+
259+
type rangeDecoder = or(null)((input: unknown): Range => {
260+
// note that the input properties differ from the output ones
261+
const { start, end } = beObject(input)
262+
263+
if (!isNumber(start) || !isNumber(end) || end > start) fail('Invalid range')
264+
265+
return { min: start, max: end }
266+
})
267+
```
169268
170-
Fail early, I’d say.
269+
Note how the earlier examples mostly compose functions. As you see here, you
270+
don’t have to do it. Sure, here we still used a catching decorator™ (i.e. the
271+
result of `or(null)`), but you can also create variables, fail the decoder
272+
imperatively and do all the stuff you can normally can do in JavaScript—even
273+
though we do recommend keeping your decoders pure. And sure, you could write
274+
this particular decoder in a more functional fashion, but the point is you don’t
275+
have too. `be-good` is not hellbent on forcing a particular programming style.
276+
277+
Another important thing is using `fail` to... well, fail the decoder. Notice
278+
how, like `beObject`, `fail` is used inside a function wrapped in a catching
279+
decorator. You don’t want unchecked exceptions everywhere. And while on one hand
280+
you have to remember about the exceptions, on the other hand you’re not
281+
recommended to throw exceptions manually. If you want to fail your decoder, call
282+
`fail`. Right now it doesn’t do much, but it might in the future, so don’t break
283+
the abstraction.
171284
172285
## Todos
173286
174287
- [x] `beDictOf`
175288
- [ ] proper Readme
176289
- [ ] decoding sum types (discriminated unions)
177290
- [ ] more examples
178-
- [ ] lenses?

src/index.test.ts

+16
Original file line numberDiff line numberDiff line change
@@ -344,3 +344,19 @@ test('advanced stuff: nested fallbacks', () => {
344344
]
345345
})
346346
})
347+
348+
test('custom predicates', () => {
349+
enum EvenNumBrand {}
350+
type EvenNum = number & EvenNumBrand
351+
const isEven = (n: unknown): n is EvenNum => isNumber(n) && n % 2 === 0
352+
const beEven = be(isEven)
353+
354+
expect(beEven(0)).toBe(0)
355+
expect(beEven(-14)).toBe(-14)
356+
expect(beEven(72)).toBe(72)
357+
expect(() => beEven(7)).toThrow()
358+
expect(() => beEven(0.4)).toThrow()
359+
expect(() => beEven(-11)).toThrow()
360+
361+
const evenZ = beEven(0)
362+
})

src/index.ts

+17-9
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@ export type Decoder<T> = (input: unknown) => T
1111
*/
1212
export function be<T>(predicate: (a: any) => a is T): Decoder<T> {
1313
return function decoder(input: unknown): T {
14-
if (!predicate(input))
15-
throw TypeError(`assertion failed on ${printValueInfo(input)}`)
14+
if (!predicate(input)) fail(`assertion failed on ${printValueInfo(input)}`)
1615

1716
return input
1817
}
@@ -132,13 +131,12 @@ export function beDictOf<ElOut>(
132131
case 'single':
133132
break // swallow the error, move on to the next element
134133
case 'all':
135-
throw TypeError('invalid element')
134+
fail('invalid element')
136135
}
137136
}
138137
})
139138

140-
if (length < minSize)
141-
throw TypeError(`Dic elements count less than ${minSize}`)
139+
if (length < minSize) fail(`Dic elements count less than ${minSize}`)
142140

143141
return result
144142
}
@@ -161,7 +159,7 @@ export function beArrayOf<ElOut>(
161159
{ invalidate = 'single', minSize = 0 }: BeCollectionOptions = {}
162160
) {
163161
return function arrayDecoder(input: unknown): ElOut[] {
164-
if (!Array.isArray(input)) throw TypeError('Not an array')
162+
if (!Array.isArray(input)) fail('Not an array')
165163

166164
const result = []
167165
for (const el of input as unknown[]) {
@@ -172,18 +170,28 @@ export function beArrayOf<ElOut>(
172170
case 'single':
173171
break // swallow the error, move on to the next element
174172
case 'all':
175-
throw TypeError('invalid element')
173+
fail('invalid element')
176174
}
177175
}
178176
}
179177

180-
if (result.length < minSize)
181-
throw TypeError(`Array length less than ${minSize}`)
178+
if (result.length < minSize) fail(`Array length less than ${minSize}`)
182179

183180
return result
184181
}
185182
}
186183

184+
/**
185+
* Fails a decoder inside which it is called. Useful for custom decoders.
186+
* The API is undecided upon, but using `fail` is still more future-proof
187+
* than simply throwing.
188+
* @param {string} [message] An error message
189+
* @returns {never}. Literally, never, ever returns.
190+
*/
191+
export function fail(message?: string, _value?: any): never {
192+
throw TypeError(message)
193+
}
194+
187195
function printValueInfo(value: any) {
188196
const [lq, rq] = typeof value === 'string' ? ['“', '”'] : ['', '']
189197
return `${lq + value + rq} (${typeof value})`

0 commit comments

Comments
 (0)