Skip to content

Conversation

@mathpirate
Copy link
Contributor

@mathpirate mathpirate commented Sep 29, 2025

  • Remove the Promise return type option
  • Use Typescript's inferred types to autogenerate schemas even when the programmer doesn't explicitly provide type template values for both lift and derive

Summary by cubic

Removes async support from derive/lift and adds automatic schema generation using TypeScript type inference when generics are omitted. This simplifies the API and cuts boilerplate by inferring and injecting schemas at build time.

  • New Features

    • Auto-generate argument and result JSONSchema for lift and derive from function parameter and return types (when generics are not provided).
    • Added TypeRegistry to pass TypeScript Types between transformer stages for reliable schema generation.
    • Schema injection now infers types and fills missing sides with unknown when only one can be determined.
  • Migration

    • derive and lift no longer accept Promise-returning functions; callbacks must be synchronous.
    • Update any async derive/lift usage to sync functions or an alternative API that supports async.

…ixture showing we do correct inference and schema generation now
/// <cts-enable />
import { handler, JSONSchema } from "commontools";
// No type annotations at all - should generate false schemas (unknown type)
export const genericHandler = handler(false as const satisfies JSONSchema, false as const satisfies JSONSchema, (event, state) => {
Copy link
Contributor

@ubik2 ubik2 Sep 29, 2025

Choose a reason for hiding this comment

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

I don't think we need the as const satisfies JSONSchema when we use true or false. It shouldn't be a problem, but it's more verbose.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

that makes sense, thanks. i took this linear issue to come back to this to try to wrangle this PR in a more manageable form :) https://linear.app/common-tools/issue/CT-954/dont-generate-as-const-satisfies-jsonschema-for-truefalse-schemas

* Uses WeakMap with node identity as key. Node identity is preserved when
* transformers are applied in sequence via ts.transform().
*/
export class TypeRegistry {
Copy link
Collaborator

Choose a reason for hiding this comment

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

type TypeRegistry = WeakMap<ts.Node, ts.Type> would suffice here

Copy link
Contributor Author

Choose a reason for hiding this comment

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

ooooh yes great point thank you, done

return [
createModularOpaqueRefTransformer(program),
createSchemaTransformer(program),
createModularOpaqueRefTransformer(program, { typeRegistry }),
Copy link
Collaborator

Choose a reason for hiding this comment

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

What are the lifetime of the ts.Nodes in the weak map? I didn't think the Program held a reference to all nodes it's created, and if it doesn't, what persists between these transformation calls that ensures the weak map does not clean its entries?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I honestly don't have a great theoretical answer here - it appears empirically to be the case that Typescript's transformation pipeline passes the updated-in-place SourceFile from one transformer to the next, such that when the later transformers use the SourceFile object (which is the root node of the AST) they receive to walk the AST, they encounter the Nodes that were created/modified by the previous transformers in the pipeline.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Confirmed that even when using a transformed (new) SourceFile, walking produces the same (instance equality) nodes when it can -- probably stored in the Program

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for checking on that

// First check if we have a registered Type for this node
// (from schema-injection when synthetic TypeNodes were created)
let type: ts.Type;
if (typeRegistry && typeRegistry.has(node)) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Are these the same Nodes that have registered to the type registry, e.g. the same instance (as opposed to a different instance representing the same underlying syntax fragment)?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Ah I see the comment that node identity is preserved during transformations

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yeah it's the same Node object with the same identity

/// <cts-enable />
import { derive, h } from "commontools";
declare const value: number;
// This derive is INSIDE JSX and should NOT be transformed with schemas
Copy link
Contributor

Choose a reason for hiding this comment

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

we should transform derive, handler, etc. inside JSX as well + add schemas, even for the ones that are automatically added

Copy link
Contributor Author

Choose a reason for hiding this comment

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

thanks for noticing this, i fixed this now

if (typeof valueSchema === "boolean") {
// Boolean schemas (true/false) cannot have properties
// Wrap in an object schema
return {
Copy link
Contributor

@ubik2 ubik2 Sep 30, 2025

Choose a reason for hiding this comment

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

rather than { allOf: [valueSchema], default: defaultValue} here, can we do the { default: defaultValue } / {not: true, default: defaultValue}?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

sure thing, done!

Copy link
Contributor

Choose a reason for hiding this comment

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

Not sure if the inclusion of these three type-inference files was intentional.
They're useful, since other devs will probably want to explore the functionality as well, so reasonable to include.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I was thinking of leaving them in through the merge to main, then maybe deleting them off main later - that way they'd be in the repo history but not taking up space in the folder... but I'm open to whatever, not sure what's best!

Copy link
Contributor

@ubik2 ubik2 left a comment

Choose a reason for hiding this comment

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

lgtm!

@mathpirate mathpirate merged commit a3b4717 into main Sep 30, 2025
7 checks passed
@mathpirate mathpirate deleted the feature-remove-derive-promise branch September 30, 2025 20:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants