This is a deserialization generator for JavaScript objects that are under
Flow. Some deserializer/validator projects use $ObjMap
and some clever casting
to achieve a type safe means of deserialization/validation. flow-degen
aims to
leverage Flow itself in ensuring type safety by generating all of that ugly
deserialization/validation code you would have written by hand.
Pros:
flow-degen
introduces no runtime dependencies to consumers, other than itself.flow-degen
emits generators that use plain Flow type checking. There is noany
casting internally, and there are no magic types.
Cons:
flow-degen
does not currently provide a list of deserializer errors, but instead bails on the first error.- There is potentially a memory/storage footprint concern for non-trivial sizes
and amounts of deserializers. A minifier may significantly mitigate this con.
Using
degenRefiner
to reference other refiners can also reduce the footprint by calling other refiners instead of duplicating refinement logic in multiple places.
yarn add -E -D flow-degen
You’ll need a config file to work as inputs to flow-degen
. It has the
structure below. Any paths or file names that start with ./
are intended to
show a relative directory.
{
"baseDir": "",
"generatedPreamble": "",
"generators": [
{
"exports": {
"fooGenerator": "fooRefiner"
},
"inputFile": "./dir/generator-input-file.js",
"outputFile": "deserializer-output-file.js"
}
],
"importLocations": {
"importName": "./dir/runtime.js"
},
"typeLocations": {
"TypeName": "./dir/type.js"
}
}
baseDir
is the directory that the generated file will be assumed to be
living out of relative to the imports.
generatedPreamble
is the code or text you want to appear at the top of the
file. You can use this to insert a copyright comment block, include linter
rules (such as disabling ESLint’s no-used-expressions rule which can be an
issue for disjoint unions).
This is a list of generators and how they produce refiners. Generators here have the following structure:
{
"exports": {
"fooGenerator": "fooRefiner",
"barGenerator": "barRefiner",
"bazGenerator": "bazRefiner"
},
"inputFile": "foo-generator.js",
"outputFile": "foo-refiner.js"
}
exports
is a mapping of identifiers exported from the generator file that
flow-degen
can find, and it maps to identifiers it will generate as
refiners for that associated generator. In the sample configuration,
fooGenerator
is found in foo-generator.js
, and it will emit a
fooRefiner
to foo-refiner.js
that you can then import
or require
to use.
exports
is also implicitly added to importLocations
such that your
refiners can refer to each other, and even achieve recursive calls if your
structure requires recursion.
This is a mapping of import names (which must be valid JavaScript
identifiers) to files. The identifiers must map to export
entities inside
of your module. These will be hoisted to the top of the generated
deserializer files if they are used. Including entries in here does not mean
it will be used in your files, it is simply a lookup for flow-degen
to
use.
Just like importLocations
, typeLocations
is a reference for export
type
identifiers so the generated deserializer can find them if any of the
combined deserializers use it.
import
from flow-degen
to get deserialization generators.
degenField
is meant to be used in conjunction with degenObject
.
This is just an alias for degenString
currently, but could one day
encompass a Flow opaque type that, while represented by a string, is
ensured to be a valid file path.
Requires a deserializer to be used for the element type, which is provided
as its only argument. This will produce an Array<T>
.
Suppose we have a foos-generator.js
:
import { degenList, degenNumber} from 'flow-degen'
const numberType = { name: 'number', typeParams: [] }
export const foosGenerator = () => degenList(numberType, degenNumber())
Upon importing the emitted file, you can now refine into an Array
of
number
:
import { deFoos } from './foos-refiner.js'
deFoos([1, 2, 3]) // Produces [1, 2, 3].
deFoos('farsnaggle') // Produces Error object.
declare var someInput: mixed
const eitherResult = deFoos(someInput)
if(eitherResult instanceof Error) {
// Here the result did not refine correctly.
console.error('How did this happen?', eitherResult)
} else {
// Now you have an Array of number.
console.log(eitherResult.map(x => x + 1))
}
A “mapping” is of the type {[A]: B}
although usually it will be
{[string]: mixed}
. It takes the key meta type, the value meta type, a key
deserializer, and a value deserializer for A
and B
respectively.
The degenMaybe
generator is for creating refiners for maybe types (e.g.
type Foo = ?string). The maybe type will still require additional
refinement after passing through the refiner. For example, given the type:
export type Foo = {
bar: ?string,
}
And generator:
import { degenObject, degenField, degenMaybe, degenString } from 'flow-degen'
const fooType = { name: 'Foo' }
const stringType = { name: 'string' }
export const fooGenerator = () => degenObject(fooType, [
degenField('bar', degenMaybe(stringType, degenString())),
])
The refiner would be used like so:
import { deFoo } from './foo-refiner.js'
declare var someInput: mixed
const eitherResult = deFoo(someInput)
if(eitherResult instanceof Error) {
// Here the result did not refine correctly.
console.error('How did this happen?', eitherResult)
} else {
// We have a foo, but bar may not have been present
if (eitherResult.bar != null) {
console.log(eitherResult.bar + ' was refined')
} else {
console.log('result had a null bar')
}
}
The degenNumber
deserializer simply deserializes a value as a number
.
An “Object” can be thought of as a collection of “fields”. See degenField
as these go together except for empty objects. degenObject
takes the type
of the object and a list of required fields that degenField
can emit, and
a second list of degenField
results that represent the optional fields.
Assume the object Cat
.
export type Cat = {
// Cats always have demands.
demands: number,
// Cats can have no love sometimes.
love?: number,
}
const catType = { name: 'Cat' }
const catGenerator = () => degenObject(catType, [
degenField('demands', degenNumber()),
], [
degenField('love', degenNumber()),
])
It is well known that cats always have demands
but only sometimes have
love
. It is fallacious to assume love
will always be present.
import { catRefiner } from './cat-refiner.js'
// It's pretty easy to get an unsanitized cat from anywhere, really.
handleUnsanitizedCat((input) => {
const catOrError: string | Error = catRefiner(input)
if(catOrError instanceof Error) {
goGetADog()
} else {
// We have a cat! But we can't expect love.
// Flow will also settle for a null check for love.
if(catOrError.hasOwnProperty('love')) {
console.log(`My cat loves me ${catOrError.love} love units!`)
} else {
console.log('My cat does not have any love for me at all...')
}
}
})
The degenString
deserializer simply deserializes a value as a string
.
Say we have a name-generator.js
:
import { degenString } from 'flow-degen'
export const nameGenerator = () => degenString()
And this is configured to produce a name-refiner.js
, this is how it would
be used:
import { nameRefiner } from './name-refiner.js'
// This could be an HTTP POST handler on a server, or a form handler on a UI
handleUnsanitizedInput((input) => {
const nameOrError: string | Error = nameRefiner(input)
if(nameOrError instanceof Error) {
console.error(nameOrError)
} else {
// Here can we use the name.
storeName(nameOrError)
}
})
This deserializer is to be used in conjunction with degenSum
to produce
deserializers for a sum type. This represents one member of the union. It
needs a key
, which is a string value for the sentinel value, and the
object deserializer itself, which will likely be degenObject
.
The degenSum
deserializer handles sum type objects. It takes the type of
the union, the sentinel field name, the sentinel field type, and a list of
sentinel object deserializers (which can just come from degenObject
) from
degenSentinelValue
.
The degenValue
deserializer takes a type
(as a string) and a value
(which could be anything). It checks for the literal equivalence of that
value. This can be helpful when using Flow’s sentinel properties for sum
types of objects.
The degenRefiner
refiner simply imports a symbol for use. This allows
recursion to work when the refined data structure is recursive. Also it
allows for reuse of other refiners of any kind. This reduces the size of
generated refiners significantly. Otherwise the refiners are inlined.
Suppose we have a foo-generator.js
whose generator builds the deFoo
refiner:
import { degenObject, degenField, degenString } from 'flow-degen'
const fooType = { name: 'Foo' }
export const fooGenerator = () => degenObject(fooType, [
degenField('first', degenString()),
degenField('last', degenString()),
])
And we have a bar-generator.js
:
import {
degenObject,
degenField,
degenString,
degenRefiner,
} from 'flow-degen'
// This is the same fooType in foo-generator.js, and could be imported.
const fooType = { name: 'Foo' }
const barType = { name: 'Bar' }
export const fooGenerator = () => degenObject(barType, [
degenField('foo', degenRefiner(fooType, 'deFoo')),
])
Here the generator will simply invoke deFoo
to refine the foo
field.
Any import, type, and hoist information will be made available in this
refiner.
Note that this symbol must be one that is managed by flow-degen
in your
configuration file, or your configuration file must specify in the
imports
how to find this symbol.
Objects of type MetaType
are passed into many generator functions and
contain information flow-degen
uses to build imports and type signatures
in the generated code. The MetaType
type can be found in
src/generator.js
but at a minimum contains the type name:
type Foo = {
bar: string,
}
const fooType = { name: 'Foo' }
In the case of generic types, the optional typeParams
field in MetaType
can be used to list the meta types to be specified in the type signature:
type Foo<K: string, V: string> = {
[K]: V,
}
const stringType = { name: 'string' }
const fooType = { name: 'Foo', typeParams: [stringType, stringType]}
Some types (for example flow utility types like $PropertyType
) take
literal strings or numbers instead of a type. The MetaType
has an optional
literal
boolean to indicate these usages:
type Foo = {
bar: {
baz: string,
},
}
const fooType = { name: 'Foo' }
const bazPropertyType = { name: "'baz'", literal: true }
const barType = { name: '$PropertyType', typeParams: [fooType, bazPropertyType] }
Note that string literals (and other literals with delimiters) need to include the delimiters in the name (e.g. “‘baz’” instead of “baz” or ‘baz’).
All deserializers must satisfy the following contract:
- They must be a function.
- The function returns a
DeserializerGenerator<CustomType: string, CustomImport: string>
, which is a tuple of a function that returns astring
(the code) and aCodeGenDep<CustomType: string, CustomImport: string>
. The exacts of these types can be found in./src/generator.js
. - The code returned by the function must accept a
mixed
as a parameter. This is your input provided from your mystery variable. It is assumed to be “deserialized” already in the sense that it is not a string of JSON but perhaps the result ofJSON.parse
. - If any imports are used, they must be enumerated in the
imports
list of theCodeGenDep
. Any imports used by the generated function will also need to be part of theCustomImport
type parameter of the generator as well as included inimportLocations
in yourflow-degen
configuration file (adding an import toimportLocations
is not necessary if the import is an export from a refiner defined in yourflow-degen
config). - If any type imports are used, they must be enumerated in the
types
list of theCodeGenDep
. Any types used by the generated function will also need to be part of theCustomType
type parameter of the generator as well as included intypeLocations
in yourflow-degen
configuration file. - Consider that your generated code could likely be embedded deep within a
function chain. If you need some “root” access to the module to declare
things such as throw-away types, use the
hoists
list to place code. - If your generator delegates to other generators (such as
degenList
delegating to a deserializer for the elements), you must honor the results of itsCodeGenDep
when you call the generator. This could mean merging theCodeGenDep
with your own. ThemergeDeps
function in./src/generator.js
does this for you. It is found byflow-degen
consumers as a top-level export (=import { mergeDeps } from ‘flow-degen’=). - Try testing your refiner with an opaque type. This seems to be a good way to ensure Flow cannot run into issues with type inferencing. We suspect this is a good test because opaque types can never be inferred, and therefore will always need explicit types at the call site of a refiner.
Let’s create an custom generator example where we have an uppercase string.
import {
degenString,
mergeDeps,
type DeserializerGenerator,
} from 'flow-degen'
import {
type UppercaseString,
uppercase,
} from './my-string-utils.js'
type UppercaseGeneratorType =
| 'UppercaseString'
type UppercaseGeneratorImport =
| 'uppercase'
export const degenUppercaseString = (
): DeserializerGenerator<UppercaseGeneratorType, UppercaseGeneratorImport> => {
const [ stringGenerator, stringDeps ] = degenString()
return [
() => {
return `(x: mixed): UppercaseString => {
return uppercase(${stringGenerator()})
}`
},
mergeDeps(
stringDeps,
{
hoists: [],
imports: [ 'uppercase' ],
types: [ { name: 'UppercaseString' } ],
},
),
]
}
Custom generators are no different from the built-in generators.
import {
degenUppercaseString,
} from './custom-degens.js'
export const generateUppercaseStringRefiner = () => degenUppercaseString()
The built-in generators in src/generators.js
can be used as more complex
examples for building your own generators.
Once installed, you can use the flow-degen
script to generate your
deserializers:
yarn flow-degen degen-config.json
The output files you indicate will export refiner functions defined in the
exports
config for the generator. The refiner functions take the form of
(mixed) => T | Error
.
import fs from 'fs'
import { fooDeserializer } from './foo.deserializer.js'
const unvalidatedFoo = JSON.parse(fs.readFileSync('foo.json', 'utf8'))
const fooOrError = fooDeserializer(unvalidatedFoo)
// Refine the result.
if(fooOrError instanceof Error) {
console.error('Error deserializing foo:', fooOrError)
} else {
doStuffWithFoo(fooOrError)
}
Do not edit these files directly except for debugging purposes. The files will be overwritten on subsequent runs of the generator. Also, the code written there is not designed with human maintainability as its chief concern.
Tooling could be built to make the generation process opaque to a consumer,
but at the time that method is not known to flow-degen
maintainers. It is
fine and even recommended to check your generated deserializers into source
control.
When using degenSum
, ESLint has a no-unused-expressions rule that fails
during a cast in the default
case. This expression doesn’t do anything in
the runtime, but Flow needs it to tie the “everything else” match to the
default
case. This makes Flow flag an error when a member of the union
isn’t enumerated in the switch
. To work around this issue, you can add //
eslint-disable no-unused-expressions
to your configuration’s
generatedPreamble
.
The config object above is generated from config-generator.js
which in turn
must deserialize itself in order to build the generator. mind-blown.gif