combo: add `exactly` combinator for fixed values (#1829)

Summary:
Write `C.exactly(["red", "green", "blue"])` to admit any of a fixed set
of primitive values. This can already be implemented in terms of `raw`
and `fmap`, but it’s more convenient to have a dedicated helper.

Test Plan:
Unit tests included, retaining full coverage.

wchargin-branch: combo-exactly
This commit is contained in:
William Chargin 2020-05-31 22:33:27 -07:00 committed by GitHub
parent ee0beaf1f1
commit ae181c2fda
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 71 additions and 0 deletions

View File

@ -114,6 +114,35 @@ export function pure<T>(t: T): Parser<T> {
return new Parser((_) => success(t));
}
// Create a parser that accepts any value from a fixed set. This can be
// used for enumerated values in configs:
//
// type Environment = "dev" | "prod";
// const p: C.Parser<Environment> = C.exactly(["dev", "prod"]);
//
// This function only supports value types. Performing an `any`-cast
// guarded by a deep equality check would be unsound, breaking opaque
// type boundaries: e.g., a module could `export opaque type T = {}` and
// provide two constants `ONE = {}` and `TWO = {}` (different objects),
// and then expect that any value of type `T` would be identical to
// either `ONE` or `TWO`. Using strict reference equality for array and
// object types would be sound, but would not usually be what was
// wanted, as it wouldn't match ~any actual output of `JSON.parse`.
export function exactly<T: string | number | boolean | null>(
ts: $ReadOnlyArray<T>
): Parser<T> {
return new Parser((x) => {
for (const t of ts) {
if (x === t) {
return success(t);
}
}
const expected: string =
ts.length === 1 ? String(ts[0]) : `one of ${JSON.stringify(ts)}`;
return failure(`expected ${expected}, got ${typename(x)}`);
});
}
// Transform the output of a parser with a pure function. For instance,
// if `p: Parser<number>` and `f = (n: number) => n % 2 === 0`, then
// `fmap(p, f)` is a `Parser<boolean>` that first uses `p` to parse its

View File

@ -106,6 +106,48 @@ describe("src/util/combo", () => {
});
});
describe("exactly", () => {
it("is type-safe", () => {
(C.exactly([1, 2, 3]): C.Parser<1 | 2 | 3>);
(C.exactly(["one", 2]): C.Parser<"one" | 2>);
(C.exactly([]): C.Parser<empty>);
// $ExpectFlowError
(C.exactly([1, 2, 3]): C.Parser<1 | 2>);
// $ExpectFlowError
(C.exactly(["one", 2]): C.Parser<1 | "two">);
// $ExpectFlowError
(C.exactly([false]): C.Parser<empty>);
});
it("accepts any matching value", () => {
type Color = "RED" | "GREEN" | "BLUE";
const p: C.Parser<Color> = C.exactly(["RED", "GREEN", "BLUE"]);
expect([p.parse("RED"), p.parse("GREEN"), p.parse("BLUE")]).toEqual([
{ok: true, value: "RED"},
{ok: true, value: "GREEN"},
{ok: true, value: "BLUE"},
]);
});
it("rejects a non-matching value among multiple alternatives", () => {
type Color = "RED" | "GREEN" | "BLUE";
const p: C.Parser<Color> = C.exactly(["RED", "GREEN", "BLUE"]);
const thunk = () => p.parseOrThrow("YELLOW");
expect(thunk).toThrow(
'expected one of ["RED","GREEN","BLUE"], got string'
);
});
it("rejects a non-matching value from just one option", () => {
type Consent = {|+acceptedEula: true|};
const p: C.Parser<Consent> = C.object({acceptedEula: C.exactly([true])});
const thunk = () => p.parseOrThrow({acceptedEula: false});
expect(thunk).toThrow("expected true, got boolean");
});
it("rejects a non-matching value from no options", () => {
const p: C.Parser<empty> = C.exactly([]);
const thunk = () => p.parseOrThrow("wat");
expect(thunk).toThrow("expected one of [], got string");
});
});
describe("fmap", () => {
type Color = "RED" | "GREEN" | "BLUE";
function stringToColor(s: string): Color {