Work in progress. Full docs pending. API breakage not out of question.
This is a set of building blocks for type-safe JSON decoders, written in and for TypeScript. JSON decoders are well-known in languages like Elm or ReasonML, and their goal is, basically, to validate external data and guarantee it really is what the types say it is, so you can safely rely on your types.
It’s functional in the right places, it’s imperative-friendly, it’s flexible—it is, dare I say, pragmatic.
yarn add be-good
Or
npm i -S be-good
The basest building block is the be
fabric:
import isString from 'lodash/isString'
import { be } from 'be-good'
const beString = be(isString)
be
takes a
user-defined type guard.
E.g., lodash’s isString
is typed as (value: any): value is string
, so
TypeScript infers the beString
return type as string
.
The functions that be
returns (like beSting
) are called decoders. A decoder
is a function that takes an unknown value and returns a value of a proven type.
But, it is possible that a decoder cannot return a value of the necessary type,
e.g., the input is invalid. In those cases, a decoder throws. Therefore, you might
want to wrap all of your decoder invocations in try/catch
, or...
The second base function is or
: a factory for catching decorators. Sounds
complicated, but it’s actually quite simple:
import { be, or } from 'be-good'
const optional = or(undefined) // a catching decorator
const beString = be(isString) // + a decoder
const beOptionalString = optional(beString) // = a decorated decoder
beOptionalString('Catchers in the Rye') // 'Catchers in the Rye`
beOptionalString(-1) // undefined
To describe what happens above:
- you apply
or
to a value (here,undefined
) and get back a decorator - you apply the decorator to the decoder and get back a new decoder
- if the new decoder is given a valid input value (here, a string), it returns that value
- otherwise, it returns a fallback (
undefined
)
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:
const alwaysBeNumber = or(0)(be(isNumber))
And sure, you can create one-off decorators on the fly. On the other hand, you
may want to keep some of them (like the optional
above) reusable across your
app.
There’s a pretty low-level decoder called beObject
that simply asserts that
the value is indeed an object. It’s useful if you’re doing some non-standard
stuff, like transforming your data instead of simply decoding—we’ll cover those
scenarios later.
For the most scenarios, there’s a more convenient decoder: beObjectOf
.
import { be, beObjectOf, or } from 'be-good'
import { isBoolean, isNumber, isString } from 'lodash'
const beBoolean = be(isBoolean)
const beNumber = be(isNumber)
const beString = be(isString)
const orNull = or(null)
type Mercenary = {
name: string
fee: number
hasGun: boolean
willTravel: boolean
}
const mercenaryDecoder = orNull(
beObjectOf<Mercenary>({
name: beString,
fee: beNumber,
hasGun: beBoolean,
willTravel: beBoolean
})
)
Never mind the silliness a mercenary without a gun that won’t travel (must be real good at sitting by the river waiting for those bodies), here’s how the decoder works.
mercenaryDecoder({ name: 'Al', fee: 100, hasGun: true, willTravel: true })
// input is an object, has all the fields, hence the decoder returns a Mercenary
mercenaryDecoder({
name: 'Will',
fee: 50_000_000,
hasGun: true,
willTravel: 'No, Will Smith'
})
// is object, right properties, wrong type, => null
mercenaryDecoder({
name: 'Sara',
hasGun: true,
willTravel: true
})
// is object, missing fields, => null
mercenaryDecoder('I’m a mercenary, honest!')
// duh, not even an object => null
mercenaryDecoder({
name: 'Spike',
middleName: 'Danger',
anotherMiddleName: 'Caution',
surName: 'DeGuano',
maidenName: 'Von Ulyanov',
fee: 20,
hasGun: false,
willTravel: true
})
// extra properties don’t matter => Mercenary
Actually, you don’t have to write beObjectOf<Mercenary>
. The decoder return
type will be inferred from the property decoders you gave to beObjectOf
: e.g.
beObjectOf({ a: beString })
will have type (x: unknown) => { a : string }
.
And since TypeScript types are structural, it doesn’t matter how the type is
called as long as the shape is right.
Then again, if you make a mistake in a property name or decoder you give to
beObjectOf
, TypeScript will fail—somewhere—and the error message might point
to a place far from where the actual error is, and you’ll spend more time fixing
it. Better specify the expected type right inside a decoder (like above), or
maybe right outside of it, like this:
import { beOjbectOf, Decoder } from 'be-good'
// ...
const objDecoder: Decoder<Type> = optional(beObjectOf(/* ... */))
Fail early, I say.
beArrayOf
and beDictOf
are similar to beObjectOf
, but their parameters
are a bit different. First, they take a single element decoder—meaning all
the elements are supposed to be of the same type. Second, the fabric has
some other options:
type BeCollectionOptions = {
/** What to invalidate on errors */
invalidate?: 'single' | 'all'
/** Minimum count of (valid) collection elements */
minSize?: number
}
Some examples:
const beNumber = be(isNumber)
beArrayOf(beNumber)([3, 25.4, false, -7])
// [3, 25.4, -7], because by default, `invalidate` option is 'singe',
// and that means simply omitting invalid elements
beArrayOf(beNumber, { invalidate: 'all' })([3, 25.4, false, -7])
// throws on the first bad element (use `or(...)`)
const orFallback = or('<fallback>')
beArrayOf(orFallback(beNumber))([3, 25.4, false, -7])
// [3, 25.4, '<fallback>', -7], compare to the first example
beArrayOf(beNumber, { minSize: 4 })([3, 25.4, false, -7])
// throws: only 3 valid elements
/* beDictOf is about the same: */
beDictOf(beNumber)({ a: 3, b: 25.4, c: false, d: -7 })
// { a: 3, b: 25.4, d: -7 }
// etc...
The type guard functions from lodash is handy, but what if you want to check for more?
const isEven = (n: unknown): n is number => isNumber(n) && n % 2 === 0;
const beEven = be(isEven) // unknown => number
If you dig opaque types, you can use them too (the enum trick taken from an article by Patrick Bacon).
enum PriceBrand {}
type Price = number & PriceBrand
const isPrice = (n: unknown): n is Price => isNumber(n) && n > 0
const bePrice = be(isPrice) // unknown => price
You can also write whole custom decoders, be it because you cannot validate
fields separately, or because you need to mess with manipulate your data
structure:
import { be, beObject, fail, or } from 'be-good'
type Range = {
min: number;
max: number;
}
type rangeDecoder = or(null)((input: unknown): Range => {
// note that the input properties differ from the output ones
const { start, end } = beObject(input)
if (!isNumber(start) || !isNumber(end) || end > start) fail('Invalid range')
return { min: start, max: end }
})
Note how the earlier examples mostly compose functions. As you see here, you
don’t have to do it. Sure, here we still used a catching decorator™ (i.e. the
result of or(null)
), but you can also create variables, fail the decoder
imperatively and do all the stuff you can normally can do in JavaScript—even
though we do recommend keeping your decoders pure. And sure, you could write
this particular decoder in a more functional fashion, but the point is you don’t
have too. be-good
is not hellbent on forcing a particular programming style.
Another important thing is using fail
to... well, fail the decoder. Notice
how, like beObject
, fail
is used inside a function wrapped in a catching
decorator. You don’t want unchecked exceptions everywhere. And while on one hand
you have to remember about the exceptions, on the other hand you’re not
recommended to throw exceptions manually. If you want to fail your decoder, call
fail
. Right now it doesn’t do much, but it might in the future, so don’t break
the abstraction.
-
beDictOf
- proper Readme
- decoding sum types (discriminated unions)
- more examples