Skip to content

Add tests to ensure Perseus JSON parsers are idempotent #2403

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 22, 2025

Conversation

benchristel
Copy link
Member

@benchristel benchristel commented Apr 17, 2025

Summary:

I realized that the type of the defaulted parser encouraged non-idempotent
parsing. Essentially, a default value could be supplied that would cause a parse
error on a second pass through the parser. Example:

const parseMyObject = object({
  myField: defaulted(number, () => "bork"),
})

The inferred type ParsedValue<typeof parseMyObject> would be:

type MyObject = {myField: number | string}

However, the parser doesn't consider a myField value of type string to be valid!
This contradicts the types! You will get an error if you try to parse

parseMyObject({myField: "bork"}, ctx)

because the parser for myField only accepts a number, null, or undefined as
input.

This is clearly not how we want parsers to behave. Parsing should be idempotent.

This PR fixes the types of defaulted so we don't encourage non-idempotent
parsing. It also adds tests to verify that our existing parsers are idempotent
when run on example data.

Issue: LEMS-3055

Test plan:

pnpm test

Copy link
Contributor

github-actions bot commented Apr 17, 2025

Size Change: +14 B (0%)

Total Size: 466 kB

Filename Size Change
packages/perseus-core/dist/es/index.js 18.5 kB +14 B (+0.08%)
ℹ️ View Unchanged
Filename Size
packages/kas/dist/es/index.js 20.7 kB
packages/keypad-context/dist/es/index.js 1 kB
packages/kmath/dist/es/index.js 5.98 kB
packages/math-input/dist/es/index.js 98.6 kB
packages/math-input/dist/es/strings.js 1.61 kB
packages/perseus-editor/dist/es/index.js 88.5 kB
packages/perseus-linter/dist/es/index.js 7.05 kB
packages/perseus-score/dist/es/index.js 9.04 kB
packages/perseus-utils/dist/es/index.js 403 B
packages/perseus/dist/es/index.js 199 kB
packages/perseus/dist/es/strings.js 7.49 kB
packages/pure-markdown/dist/es/index.js 1.22 kB
packages/simple-markdown/dist/es/index.js 6.71 kB

compressed-size-action

Copy link
Contributor

github-actions bot commented Apr 17, 2025

npm Snapshot: Published

Good news!! We've packaged up the latest commit from this PR (d36f830) and published it to npm. You
can install it using the tag PR2403.

Example:

pnpm add @khanacademy/perseus@PR2403

If you are working in Khan Academy's webapp, you can run:

./dev/tools/bump_perseus_version.js -t PR2403

@benchristel benchristel force-pushed the benc/mutual-assignability branch from 1f59a66 to e710c9c Compare April 17, 2025 20:31
@benchristel benchristel force-pushed the benc/idempotent-defaulted branch from 7cf70d6 to bcb6197 Compare April 17, 2025 20:52
parser: Parser<T>,
fallback: (missingValue: null | undefined) => Default,
): Parser<T | Default> {
fallback: (missingValue: null | undefined) => NoInfer<T>,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL about NoInfer<T> (I read https://www.totaltypescript.com/noinfer).

So this blocks the fallback's return value from being included in the type inference for T, right? So the return value's T will be only inferred from the parser type.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct! FWIW writing this code made me happy because I think it's a perfect place to use NoInfer.

import {defaulted} from "../general-purpose-parsers/defaulted";

import {parseImages} from "./images-map";
import {parseWidgetsMap} from "./widgets-map";

export const parseHint = object({
replace: defaulted(boolean, () => undefined),
replace: defaulted(optional(boolean), () => undefined),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not clear why you needed to wrap optional() here? Is it because the replace can be undefined and if we didn't wrap it in optional() here, we'd have a resulting type of boolean because the fallback is ignored for type inference?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep!

Comment on lines +43 to +45
// once is the same as running it many times. Idempotency is
// valuable because it means e.g. that if we run the parser on data
// before saving it to datastore, it won't be changed by being
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💯 💯

@benchristel benchristel changed the base branch from benc/mutual-assignability to main April 22, 2025 16:09
@benchristel benchristel force-pushed the benc/idempotent-defaulted branch from bcb6197 to d36f830 Compare April 22, 2025 18:31
@benchristel benchristel merged commit bfa5ce6 into main Apr 22, 2025
8 checks passed
@benchristel benchristel deleted the benc/idempotent-defaulted branch April 22, 2025 18:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants