pelotom / unionize

Boilerplate-free functional sum types in TypeScript
MIT License
402 stars 14 forks source link

Deserialising/validating/decoding JSON strings to unionize types #41

Open OliverJAsh opened 6 years ago

OliverJAsh commented 6 years ago

Do you have any suggestions how to validate values as unionize types? Here is a real world example of where this is needed.

I have a union type to represent a page modal.

const Modal = unionize({
    Foo: ofType<{ foo: number }>(),
    Bar: ofType<{ bar: number }>()
});
type Modal = typeof Modal._Union;

The modal is specified to the application through the URL as a query param, e.g. http://foo.com/?modal=VALUE, where VALUE is an object after it has been JSON stringified and URI encoded, e.g.

`?modal=${encodeURIComponent(JSON.stringify(Modal.Foo({ foo: 1 })))}`
// ?modal=%7B%22tag%22%3A%22Foo%22%2C%22foo%22%3A1%7D

In my application I want to be able to match against the modal using the match helpers provide by unionize. However, it is not safe to do so, because the modal query param could be a string containing anything, e.g. modal=foo.

For this reason I need to validate the value before matching against it.

In the past I've enjoyed using io-ts for the purpose of validating. I am aware you also have a similar library called runtypes.

If this is a common use case, I wonder if there's anything we could work into the library, or build on top of it, to make it easier.

Here is a working example that uses io-ts and tries to share as much as possible, but duplication is inevitable and it's a lot of boilerplate.

import { ofType, unionize } from "unionize";
import * as t from "io-ts";
import { option } from "fp-ts";

//
// Define our runtime types, for validation
//

const FooRT = t.type({ tag: t.literal("Foo"), foo: t.number });
type Foo = t.TypeOf<typeof FooRT>;

const BarRT = t.type({ tag: t.literal("Bar"), bar: t.number });
type Bar = t.TypeOf<typeof BarRT>;

const ModalRT = t.taggedUnion("tag", [FooRT, BarRT]);

//
// Define our unionize types, for object construction and matching
//

export const Modal = unionize({
    Foo: ofType<Foo>(),
    Bar: ofType<Bar>()
});
export type Modal = typeof Modal._Union;

//
// Example of using the unionize object constructors
//

const modalFoo = Modal.Foo({ foo: 1 });

//
// Example of validation with io-ts + matching with unionize
//

const parseJsonSafe = (str: string) => option.tryCatch(() => JSON.parse(str));

const validateModal = (str: string) => {
    console.log("validating string:", str);

    parseJsonSafe(str).foldL(
        () => {
            console.log("invalid json");
        },
        parsedJson => {
            ModalRT.decode(parsedJson).fold(
                () => console.log("parsed json, invalid modal"),
                modal =>
                    Modal.match({
                        Foo: foo =>
                            console.log("parsed json, valid modal foo", foo),
                        Bar: bar =>
                            console.log("parsed json, valid modal bar", bar)
                    })(modal)
            );
        }
    );
};

validateModal(JSON.stringify({ tag: "Foo", foo: 1 }));
/*
validating string: {"tag":"Foo","foo":1}
parsed json, valid modal foo { tag: 'Foo', foo: 1 }
*/
validateModal(JSON.stringify({ tag: "Bar", bar: 1 }));
/*
validating string: {"tag":"Bar","bar":1}
parsed json, valid modal bar { tag: 'Bar', bar: 1 }
*/
validateModal("INVALID JSON TEST");
/*
validating string: INVALID JSON TEST
invalid json
*/
pelotom commented 6 years ago

The rule I try to live by with my libraries is that they should do one very narrowly focused thing and (hopefully) do it well. This is mostly out of necessity, because when scope explodes so does the maintenance burden, but I also prefer to use libraries like that, because they compose well together and can be readily swapped out for new solutions as needed. So anyway, I think this is a great case for a composite library which leverages either io-ts or runtypes. Since it looks like io-ts has a tagged union primitive whereas runtypes doesn't, maybe that's a better starting point. An io-unionize library could theoretically take any t.taggedUnion and produce from it a unionized instance. What do you think?

OliverJAsh commented 6 years ago

Agreed!

take any t.taggedUnion and produce from it a unionized instance

How do you think this would look?

sledorze commented 6 years ago

@pelotom @OliverJAsh that would be great!

pelotom commented 6 years ago

How do you think this would look?

I'm not sure what it would look like for io-ts, but in runtypes all types are backed by a Reflect instance with fields that allow writing runtime algorithms against them. I would imagine a tagged union runtime type would have a tagName: string field and a values: Record<string, Runtype> field which maps tag values to variant types... these are the essential ingredients needed to pass to unionize.

I don't have the bandwidth to investigate this more fully, just spitballin' here 😄

OliverJAsh commented 6 years ago

I filed an issue with io-ts to see if there's a way we can incorporate the benefits of unionize into io-ts, so we have the best of both worlds:

https://github.com/gcanti/io-ts/issues/187

OliverJAsh commented 6 years ago

For anyone who is interested, this might be helpful: https://github.com/gcanti/io-ts/issues/187