Skip to content

Migrate typetests to TSTyche#3468

Merged
benchristel merged 26 commits intomainfrom
benc/tstyche
Apr 10, 2026
Merged

Migrate typetests to TSTyche#3468
benchristel merged 26 commits intomainfrom
benc/tstyche

Conversation

@benchristel
Copy link
Copy Markdown
Member

Pursuant to ADR #923, we are adopting TSTyche for type testing in TypeScript code!

Issue: LEMS-3992

Test plan:

pnpm tstyche

CI checks should pass.

Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

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

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 8, 2026

🗄️ Schema Change: No Changes ✅

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 8, 2026

npm Snapshot: Published

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

Example:

pnpm add @khanacademy/perseus@PR3468

If you are working in Khan Academy's frontend, you can run the below command.

./dev/tools/bump_perseus_version.ts -t PR3468

If you are working in Khan Academy's webapp, you can run the below command.

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

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 8, 2026

🛠️ Item Splitting: Changes Detected ⚠️

Usually this means you need to update the Go parser
that Content Platform maintains!!!

Follow these steps on how to release.
See this list of post-mortems for more information.

This PR contains critical changes to Perseus that affect published data.
Please review the changes and note that you may need to
coordinate deployment of these changes with other teams
at Khan Academy, especially with #cp-eng to publish a
content update.

diff --unified /home/runner/work/_temp/branch-compare/base/index.item-splitting.js /home/runner/work/_temp/branch-compare/pr/index.item-splitting.js
--- /home/runner/work/_temp/branch-compare/base/index.item-splitting.js	2026-04-09 23:17:53.420611453 +0000
+++ /home/runner/work/_temp/branch-compare/pr/index.item-splitting.js	2026-04-09 23:17:25.305570851 +0000
@@ -114,7 +114,7 @@
 
 const emptyStringToNull=pipeParsers(constant("")).then(convert(()=>null)).parser;const parseNumberLineWidget=parseWidget(constant("number-line"),object({range:array(number),labelRange:array(nullable(union(number).or(emptyStringToNull).parser)),labelStyle:string,labelTicks:boolean,isTickCtrl:defaulted(boolean,()=>false),isInequality:defaulted(boolean,()=>false),divisionRange:array(number),numDivisions:optional(nullable(number)),snapDivisions:defaulted(number,()=>2),tickStep:optional(nullable(number)),correctRel:defaulted(optional(enumeration("eq","lt","gt","le","ge")),()=>undefined),correctX:defaulted(nullable(number),()=>null),initialX:optional(nullable(number)),showTooltips:optional(boolean),static:defaulted(boolean,()=>false)}));
 
-const parseMathFormat=enumeration("integer","mixed","improper","proper","decimal","percent","pi");const parseSimplify=pipeParsers(union(constant(null)).or(constant(undefined)).or(boolean).or(constant("true")).or(constant("required")).or(constant("correct")).or(constant("enforced")).or(constant("optional")).or(constant("accepted")).parser).then(convert(deprecatedSimplifyValuesToRequired)).parser;function deprecatedSimplifyValuesToRequired(simplify){switch(simplify){case "enforced":case "required":case "optional":return simplify;default:return "required"}}const parseNumericInputWidget=parseWidget(constant("numeric-input"),object({answers:array(object({message:defaulted(string,()=>""),value:optional(nullable(number)),status:string,answerForms:defaulted(optional(array(parseMathFormat)),()=>undefined),strict:defaulted(boolean,()=>false),maxError:optional(nullable(number)),simplify:parseSimplify})),labelText:optional(string),size:string,coefficient:defaulted(boolean,()=>false),rightAlign:optional(boolean),static:defaulted(boolean,()=>false),answerForms:optional(array(object({name:parseMathFormat,simplify:parseSimplify})))}));
+const parseMathFormat=enumeration("integer","mixed","improper","proper","decimal","percent","pi");const parseSimplify=pipeParsers(union(constant(null)).or(constant(undefined)).or(boolean).or(constant("true")).or(constant("required")).or(constant("correct")).or(constant("enforced")).or(constant("optional")).or(constant("accepted")).parser).then(convert(deprecatedSimplifyValuesToRequired)).parser;function deprecatedSimplifyValuesToRequired(simplify){switch(simplify){case "enforced":case "required":case "optional":return simplify;default:return "required"}}const parseNumericInputWidget=parseWidget(constant("numeric-input"),object({answers:array(object({message:defaulted(string,()=>""),value:optional(nullable(number)),status:string,answerForms:defaulted(optional(array(parseMathFormat)),()=>undefined),strict:defaulted(boolean,()=>false),maxError:optional(nullable(number)),simplify:parseSimplify})),labelText:optional(string),size:string,coefficient:defaulted(boolean,()=>false),rightAlign:optional(boolean),static:defaulted(boolean,()=>false)}));
 
 function parseRenderer(rawValue,ctx){return parsePerseusRenderer(rawValue,ctx)}const largeToAuto=(height,ctx)=>{if(height==="large"){return ctx.success("auto")}return ctx.success(height)};const parseOrdererWidget=parseWidget(constant("orderer"),object({options:defaulted(array(parseRenderer),()=>[]),correctOptions:defaulted(array(parseRenderer),()=>[]),otherOptions:defaulted(array(parseRenderer),()=>[]),height:pipeParsers(enumeration("normal","auto","large")).then(largeToAuto).parser,layout:defaulted(enumeration("horizontal","vertical"),()=>"horizontal")}));
 

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 8, 2026

Size Change: -34 B (-0.01%)

Total Size: 497 kB

📦 View Changed
Filename Size Change
packages/perseus-core/dist/es/index.item-splitting.js 11.9 kB -20 B (-0.17%)
packages/perseus-core/dist/es/index.js 25.1 kB -14 B (-0.06%)
ℹ️ View Unchanged
Filename Size
packages/kas/dist/es/index.js 20.5 kB
packages/keypad-context/dist/es/index.js 1 kB
packages/kmath/dist/es/index.js 6.36 kB
packages/math-input/dist/es/index.js 98.5 kB
packages/math-input/dist/es/strings.js 1.61 kB
packages/perseus-editor/dist/es/index.js 102 kB
packages/perseus-linter/dist/es/index.js 9.3 kB
packages/perseus-score/dist/es/index.js 9.7 kB
packages/perseus-utils/dist/es/index.js 403 B
packages/perseus/dist/es/index.js 194 kB
packages/perseus/dist/es/strings.js 8.27 kB
packages/pure-markdown/dist/es/index.js 1.39 kB
packages/simple-markdown/dist/es/index.js 6.71 kB

compressed-size-action

Copy link
Copy Markdown
Collaborator

@jeremywiebe jeremywiebe left a comment

Choose a reason for hiding this comment

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

Overall I like how the tests now feel more like .. tests!

// @ts-expect-error - Type '{shape: "extra"}' is not assignable to type 'Figure'
parser satisfies Parser<Figure>;
}
expect(parsed).type.toBe<ParseResult<Figure>>();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I notice that the migrations don't do any negative assertions... is that not needed with tstyche or would it be useful to also assert here something like:

expect(parsed).type.not.toBeAssignableTo<ParseResult<string>>();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

.toBe() checks the exact shape of a type. In most cases, assignability can be assumed from this point.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes, the negative assertions in the old tests were a hack to protect against any being silently inferred. The toBe() matcher fails if one, but not both, of the compared types is any.

}
it("raises a type error if a variant does not contain the discriminant key", () => {
// @ts-expect-error Argument of type 'Parser<OptionalizeProperties<{}>>' is not assignable to parameter of type 'Parser<{ shape: Primitive; }>'
discriminatedUnionOn("shape").withBranch("circle", object({}));
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is partly because I haven't worked with these tests much, but it took me a minute to understand that the object({}) was missing a discriminator key. Am I correct in assuming there's no assertion helper that tstyche provides to make this clearer?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Perhaps .not.toBeCallableWith()?

expect(discriminatedUnionOn("shape").withBranch).type.not.toBeCallableWith("circle", object({}));

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Is there a way to assert on the error message when using not.toBeCallableWith()?

Copy link
Copy Markdown

@mrazauskas mrazauskas Apr 9, 2026

Choose a reason for hiding this comment

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

Nope. That’s a good question! I was thinking on this and drafting an API, but it all looked complicated. The conclusion was that any negated assertion (also those for JavaScript frameworks) do not reveal the exact cause of the error. But we can use positive and negative assertion with only one element changed:

expect(add).toBeCallableWith(1, 2)
expect(add).not.toBeCallableWith(1, "2") // all clear why caused the error

Or use @ts-expect-error, as you did already. In a way the message is implementation detail. It might change between TS versions. But sometimes it is useless to see the error as well. TSTyche handles both cases.

/**
* Summons a value of type T out of thin air!
*
* (Terms and conditions apply. Value will not actually be returned.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

😂

Comment on lines +21 to +22
const alphabet = union(constant("a"))
.or(constant("a"))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: is this intentional/significant to use constant("a") twice?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Nope, that's accidental. Although, it points out an interesting test case that I missed! Perhaps we should test that union(constant("a")).or(constant("a")).parser parses the same type as constant("a").

coefficient: defaulted(boolean, () => false),
rightAlign: optional(boolean),
static: defaulted(boolean, () => false),
answerForms: optional(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Was this removed because it's no longer valid?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes. When I migrated the typetests to TSTyche, I got an error that the parsed type wasn't the same as the data-schema type. This answerForms property, which isn't in data-schema, was the mismatch.

Comment on lines 31 to 37
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I noticed some tests remove the use of these types. Are they still of use?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

They are not! Good catch.

Copy link
Copy Markdown
Collaborator

@jeremywiebe jeremywiebe left a comment

Choose a reason for hiding this comment

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

Oops, hit the wrong option. Approved!

Copy link
Copy Markdown

@mrazauskas mrazauskas left a comment

Choose a reason for hiding this comment

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

Glad to see you choose TSTyche. I am its author.

Overall, the tests look very good. Please ping me if there are any questions in the future.

}
it("raises a type error if a variant does not contain the discriminant key", () => {
// @ts-expect-error Argument of type 'Parser<OptionalizeProperties<{}>>' is not assignable to parameter of type 'Parser<{ shape: Primitive; }>'
discriminatedUnionOn("shape").withBranch("circle", object({}));
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Perhaps .not.toBeCallableWith()?

expect(discriminatedUnionOn("shape").withBranch).type.not.toBeCallableWith("circle", object({}));

// @ts-expect-error - Type '{shape: "extra"}' is not assignable to type 'Figure'
parser satisfies Parser<Figure>;
}
expect(parsed).type.toBe<ParseResult<Figure>>();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

.toBe() checks the exact shape of a type. In most cases, assignability can be assumed from this point.

@benchristel benchristel merged commit cedd3f8 into main Apr 10, 2026
12 checks passed
@benchristel benchristel deleted the benc/tstyche branch April 10, 2026 00:01
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.

3 participants