-
Notifications
You must be signed in to change notification settings - Fork 350
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Redesign discriminated union type parser (#2068)
Previously, the `discriminatedUnion` parser required you to specify the discriminant via an object parser, which was confusing and verbose. Now you only have to specify the discriminant key once, and provide the values for each branch. Issue: LEMS-2582 ## Test plan: `yarn test` Author: benchristel Reviewers: jeremywiebe, benchristel, anakaren-rojas, catandthemachines, nishasy Required Reviewers: Approved By: jeremywiebe Checks: ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x) Pull Request URL: #2068
- Loading branch information
1 parent
a628c23
commit 265a931
Showing
6 changed files
with
247 additions
and
120 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"@khanacademy/perseus": patch | ||
--- | ||
|
||
Internal: Redesign discriminated union type parser to have a simpler and more intuitive interface. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
98 changes: 64 additions & 34 deletions
98
packages/perseus/src/util/parse-perseus-json/general-purpose-parsers/discriminated-union.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,46 +1,76 @@ | ||
import {isSuccess} from "../result"; | ||
|
||
import {pipeParsers} from "./pipe-parsers"; | ||
|
||
import type {Parser} from "../parser-types"; | ||
|
||
// discriminatedUnion() should be preferred over union() when parsing a | ||
// discriminated union type, because discriminatedUnion() produces more | ||
// understandable failure messages. It takes the discriminant as the source of | ||
// truth for which variant is to be parsed, and expects the other data to match | ||
// that variant. | ||
export function discriminatedUnion<T>( | ||
narrow: Parser<unknown>, | ||
parseVariant: Parser<T>, | ||
): DiscriminatedUnionBuilder<T> { | ||
return new DiscriminatedUnionBuilder( | ||
pipeParsers(narrow).then(parseVariant).parser, | ||
); | ||
import {isObject} from "./is-object"; | ||
|
||
import type {ParseContext, Parser} from "../parser-types"; | ||
|
||
type Primitive = number | string | boolean | null | undefined; | ||
|
||
/** | ||
* discriminatedUnion() should be preferred over union() when parsing a | ||
* discriminated union type, because discriminatedUnion() produces more | ||
* understandable failure messages. It takes the discriminant as the source of | ||
* truth for which variant is to be parsed, and expects the other data to match | ||
* that variant. | ||
*/ | ||
export function discriminatedUnionOn<DK extends string>(discriminantKey: DK) { | ||
const noMoreBranches: Parser<never> = (raw: unknown, ctx: ParseContext) => { | ||
if (!isObject(raw)) { | ||
return ctx.failure("object", raw); | ||
} | ||
return ctx | ||
.forSubtree(discriminantKey) | ||
.failure("a valid value", raw[discriminantKey]); | ||
}; | ||
|
||
return new DiscriminatedUnionBuilder(discriminantKey, noMoreBranches); | ||
} | ||
|
||
class DiscriminatedUnionBuilder<Variant> { | ||
constructor(public parser: Parser<Variant>) {} | ||
class DiscriminatedUnionBuilder< | ||
DK extends string, | ||
Union extends {[k in DK]: Primitive}, | ||
> { | ||
constructor( | ||
private discriminantKey: DK, | ||
public parser: Parser<Union>, | ||
) {} | ||
|
||
withBranch<Variant extends {[k in DK]: Primitive}>( | ||
discriminantValue: Primitive, | ||
parseNewVariant: Parser<Variant>, | ||
): DiscriminatedUnionBuilder<DK, Union | Variant> { | ||
const parseNewBranch = discriminatedUnionBranch( | ||
this.discriminantKey, | ||
discriminantValue, | ||
parseNewVariant, | ||
this.parser, | ||
); | ||
|
||
or<Variant2>( | ||
narrow: Parser<unknown>, | ||
parseVariant: Parser<Variant2>, | ||
): DiscriminatedUnionBuilder<Variant | Variant2> { | ||
return new DiscriminatedUnionBuilder( | ||
either(narrow, parseVariant, this.parser), | ||
this.discriminantKey, | ||
parseNewBranch, | ||
); | ||
} | ||
} | ||
|
||
function either<A, B>( | ||
narrowToA: Parser<unknown>, | ||
parseA: Parser<A>, | ||
parseB: Parser<B>, | ||
): Parser<A | B> { | ||
return (rawValue, ctx) => { | ||
if (isSuccess(narrowToA(rawValue, ctx))) { | ||
return parseA(rawValue, ctx); | ||
function discriminatedUnionBranch< | ||
DK extends string, | ||
DV extends Primitive, | ||
Variant extends {[k in DK]: DV}, | ||
Rest extends {[k in DK]: DV}, | ||
>( | ||
discriminantKey: DK, | ||
discriminantValue: DV, | ||
parseVariant: Parser<Variant>, | ||
parseOtherBranches: Parser<Rest>, | ||
): Parser<Variant | Rest> { | ||
return (raw: unknown, ctx: ParseContext) => { | ||
if (!isObject(raw)) { | ||
return ctx.failure("object", raw); | ||
} | ||
|
||
if (raw[discriminantKey] === discriminantValue) { | ||
return parseVariant(raw, ctx); | ||
} | ||
|
||
return parseB(rawValue, ctx); | ||
return parseOtherBranches(raw, ctx); | ||
}; | ||
} |
65 changes: 65 additions & 0 deletions
65
...rseus/src/util/parse-perseus-json/general-purpose-parsers/discriminated-union.typetest.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
import {constant} from "./constant"; | ||
import {discriminatedUnionOn} from "./discriminated-union"; | ||
import {number} from "./number"; | ||
import {object} from "./object"; | ||
|
||
import type {Parser} from "../parser-types"; | ||
|
||
type Figure = | ||
| {shape: "circle"; radius: number} | ||
| {shape: "rectangle"; width: number; height: number} | ||
| {shape: "square"; sideLength: number}; | ||
|
||
const parseCircle = object({ | ||
shape: constant("circle"), | ||
radius: number, | ||
}); | ||
|
||
const parseRectangle = object({ | ||
shape: constant("rectangle"), | ||
width: number, | ||
height: number, | ||
}); | ||
|
||
const parseSquare = object({ | ||
shape: constant("square"), | ||
sideLength: number, | ||
}); | ||
|
||
// Test: parsed result is assignable to the union type | ||
{ | ||
const parser = discriminatedUnionOn("shape") | ||
.withBranch("circle", parseCircle) | ||
.withBranch("rectangle", parseRectangle) | ||
.withBranch("square", parseSquare).parser; | ||
|
||
parser satisfies Parser<Figure>; | ||
|
||
// Guard against implicit 'any' type | ||
// @ts-expect-error - Type '{ shape: "circle"; radius: number; }' is not assignable to type 'string'. | ||
parser satisfies Parser<string>; | ||
} | ||
|
||
// Test: parse result with extra branches is not assignable to the union type | ||
{ | ||
const parser = discriminatedUnionOn("shape") | ||
.withBranch("circle", parseCircle) | ||
.withBranch("rectangle", parseRectangle) | ||
.withBranch("square", parseSquare) | ||
.withBranch("extra", object({shape: constant("extra")})).parser; | ||
|
||
// @ts-expect-error - Type '{shape: "extra"}' is not assignable to type 'Figure' | ||
parser satisfies Parser<Figure>; | ||
} | ||
|
||
// Test: each variant must contain the discriminant key | ||
{ | ||
// @ts-expect-error - property 'shape' is missing in type '{}' | ||
discriminatedUnionOn("shape").withBranch("circle", object({})); | ||
} | ||
|
||
// Test: each variant must be an object | ||
{ | ||
// @ts-expect-error - Type 'number' is not assignable to type '{ shape: Primitive; }'. | ||
discriminatedUnionOn("shape").withBranch("circle", number); | ||
} |
Oops, something went wrong.