Skip to content

Commit 83c6e56

Browse files
authored
Unwrap OpaqueRef<T> to T in schema generation and add 'asOpaque: true' (#1852)
* unwrap opaqueref types in schema generation and add 'asOpaque: true' to the generated schemas * fmt
1 parent 205f9bf commit 83c6e56

File tree

4 files changed

+149
-43
lines changed

4 files changed

+149
-43
lines changed

packages/schema-generator/src/formatters/common-tools-formatter.ts

Lines changed: 66 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import type {
66
} from "../interface.ts";
77
import type { SchemaGenerator } from "../schema-generator.ts";
88

9+
type WrapperKind = "Cell" | "Stream" | "OpaqueRef";
10+
911
/**
10-
* Formatter for Common Tools specific types (Cell<T>, Stream<T>, Default<T,V>)
12+
* Formatter for Common Tools specific types (Cell<T>, Stream<T>, OpaqueRef<T>, Default<T,V>)
1113
*
1214
* TypeScript handles alias resolution automatically and we don't need to
1315
* manually traverse alias chains.
@@ -33,19 +35,8 @@ export class CommonToolsFormatter implements TypeFormatter {
3335
}
3436
}
3537

36-
// Then handle Cell/Stream via resolved type (works for direct and aliased)
37-
if (type.flags & ts.TypeFlags.Object) {
38-
const objectType = type as ts.ObjectType;
39-
if (objectType.objectFlags & ts.ObjectFlags.Reference) {
40-
const typeRef = objectType as ts.TypeReference;
41-
const resolvedName = typeRef.target?.symbol?.name;
42-
if (resolvedName === "Cell" || resolvedName === "Stream") {
43-
return true;
44-
}
45-
}
46-
}
47-
48-
return false;
38+
// Check if this is a wrapper type (Cell/Stream/OpaqueRef)
39+
return this.getWrapperTypeInfo(type) !== undefined;
4940
}
5041

5142
formatType(type: ts.Type, context: GenerationContext): SchemaDefinition {
@@ -61,37 +52,28 @@ export class CommonToolsFormatter implements TypeFormatter {
6152
}
6253
}
6354

64-
// Determine wrapper kind using resolved type for Cell/Stream
65-
let resolvedTypename: string | undefined;
66-
if (type.flags & ts.TypeFlags.Object) {
67-
const objectType = type as ts.ObjectType;
68-
if (objectType.objectFlags & ts.ObjectFlags.Reference) {
69-
const typeRef = objectType as ts.TypeReference;
70-
resolvedTypename = typeRef.target?.symbol?.name;
71-
}
72-
}
73-
74-
// Handle Cell/Stream using unified wrapper path
75-
if (resolvedTypename === "Cell" || resolvedTypename === "Stream") {
76-
return this.formatCellOrStream(
77-
type as ts.TypeReference,
55+
// Get wrapper type information (Cell/Stream/OpaqueRef)
56+
const wrapperInfo = this.getWrapperTypeInfo(type);
57+
if (wrapperInfo) {
58+
return this.formatWrapperType(
59+
wrapperInfo.typeRef,
7860
n,
7961
context,
80-
resolvedTypename === "Stream",
62+
wrapperInfo.kind,
8163
);
8264
}
8365

8466
const nodeName = this.getTypeRefIdentifierName(n);
8567
throw new Error(
86-
`Unexpected CommonTools type: ${nodeName || resolvedTypename}`,
68+
`Unexpected CommonTools type: ${nodeName}`,
8769
);
8870
}
8971

90-
private formatCellOrStream(
72+
private formatWrapperType(
9173
typeRef: ts.TypeReference,
9274
typeRefNode: ts.TypeNode | undefined,
9375
context: GenerationContext,
94-
asStream: boolean,
76+
wrapperKind: WrapperKind,
9577
): SchemaDefinition {
9678
const innerTypeFromType = typeRef.typeArguments?.[0];
9779
const innerTypeNode = typeRefNode && ts.isTypeReferenceNode(typeRefNode)
@@ -105,7 +87,7 @@ export class CommonToolsFormatter implements TypeFormatter {
10587
}
10688
if (!innerType) {
10789
throw new Error(
108-
`${asStream ? "Stream" : "Cell"}<T> requires type argument`,
90+
`${wrapperKind}<T> requires type argument`,
10991
);
11092
}
11193

@@ -123,26 +105,71 @@ export class CommonToolsFormatter implements TypeFormatter {
123105
);
124106

125107
// Stream<T>: do not reflect inner Cell-ness; only mark asStream
126-
if (asStream) {
127-
// Do not reflect inner Cell markers at stream level
108+
if (wrapperKind === "Stream") {
128109
const { asCell: _drop, ...rest } = innerSchema as Record<string, unknown>;
129110
return { ...(rest as any), asStream: true } as SchemaDefinition;
130111
}
131112

132113
// Cell<T>: disallow Cell<Stream<T>> to avoid ambiguous semantics
133-
if (this.isStreamType(innerType)) {
114+
if (wrapperKind === "Cell" && this.isStreamType(innerType)) {
134115
throw new Error(
135116
"Cell<Stream<T>> is unsupported. Wrap the stream: Cell<{ stream: Stream<T> }>.",
136117
);
137118
}
138119

120+
// Determine the property name to add based on wrapper kind
121+
const propertyName = wrapperKind === "Cell" ? "asCell" : "asOpaque";
122+
139123
// Handle case where innerSchema might be boolean (per JSON Schema spec)
140124
if (typeof innerSchema === "boolean") {
141125
return innerSchema === false
142-
? { asCell: true, not: true } // false = "no value is valid"
143-
: { asCell: true }; // true = "any value is valid"
126+
? { [propertyName]: true, not: true } // false = "no value is valid"
127+
: { [propertyName]: true }; // true = "any value is valid"
144128
}
145-
return { ...innerSchema, asCell: true };
129+
return { ...innerSchema, [propertyName]: true };
130+
}
131+
132+
/**
133+
* Get wrapper type information (Cell/Stream/OpaqueRef)
134+
* Handles both direct references and intersection types (e.g., OpaqueRef<"literal">)
135+
* Returns the wrapper kind and the TypeReference needed for formatting
136+
*/
137+
private getWrapperTypeInfo(
138+
type: ts.Type,
139+
): { kind: WrapperKind; typeRef: ts.TypeReference } | undefined {
140+
// Check direct object type reference
141+
if (type.flags & ts.TypeFlags.Object) {
142+
const objectType = type as ts.ObjectType;
143+
if (objectType.objectFlags & ts.ObjectFlags.Reference) {
144+
const typeRef = objectType as ts.TypeReference;
145+
const name = typeRef.target?.symbol?.name;
146+
if (name === "Cell" || name === "Stream" || name === "OpaqueRef") {
147+
return { kind: name, typeRef };
148+
}
149+
}
150+
}
151+
152+
// OpaqueRef with literal type arguments becomes an intersection
153+
// e.g., OpaqueRef<"initial"> expands to: OpaqueRefMethods<"initial"> & "initial"
154+
// We need to detect OpaqueRefMethods to handle this case
155+
if (type.flags & ts.TypeFlags.Intersection) {
156+
const intersectionType = type as ts.IntersectionType;
157+
for (const constituent of intersectionType.types) {
158+
if (constituent.flags & ts.TypeFlags.Object) {
159+
const objectType = constituent as ts.ObjectType;
160+
if (objectType.objectFlags & ts.ObjectFlags.Reference) {
161+
const typeRef = objectType as ts.TypeReference;
162+
const name = typeRef.target?.symbol?.name;
163+
// OpaqueRefMethods is the internal type that OpaqueRef expands to
164+
if (name === "OpaqueRefMethods") {
165+
return { kind: "OpaqueRef", typeRef };
166+
}
167+
}
168+
}
169+
}
170+
}
171+
172+
return undefined;
146173
}
147174

148175
private getTypeRefIdentifierName(

packages/schema-generator/src/type-utils.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ export function getNamedTypeKey(
213213
type: ts.Type,
214214
typeNode?: ts.TypeNode,
215215
): string | undefined {
216-
// Check if the TypeNode indicates this is a wrapper type (Default/Cell/Stream)
216+
// Check if the TypeNode indicates this is a wrapper type (Default/Cell/Stream/OpaqueRef)
217217
// Even if the type symbol says it's the inner type, if it's wrapped we shouldn't hoist it
218218
if (
219219
typeNode && ts.isTypeReferenceNode(typeNode) &&
@@ -222,16 +222,17 @@ export function getNamedTypeKey(
222222
const nodeTypeName = typeNode.typeName.text;
223223
if (
224224
nodeTypeName === "Default" || nodeTypeName === "Cell" ||
225-
nodeTypeName === "Stream"
225+
nodeTypeName === "Stream" || nodeTypeName === "OpaqueRef"
226226
) {
227227
return undefined;
228228
}
229229
}
230230

231-
// Check if this is a Default/Cell/Stream wrapper type via alias
231+
// Check if this is a Default/Cell/Stream/OpaqueRef wrapper type via alias
232232
const aliasName = (type as TypeWithInternals).aliasSymbol?.name;
233233
if (
234-
aliasName === "Default" || aliasName === "Cell" || aliasName === "Stream"
234+
aliasName === "Default" || aliasName === "Cell" || aliasName === "Stream" ||
235+
aliasName === "OpaqueRef"
235236
) {
236237
return undefined;
237238
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { cell, derive, lift, JSONSchema } from "commontools";
2+
const stage = cell<string>("initial");
3+
const attemptCount = cell<number>(0);
4+
const acceptedCount = cell<number>(0);
5+
const rejectedCount = cell<number>(0);
6+
const normalizedStage = lift({
7+
type: "string"
8+
} as const satisfies JSONSchema, {
9+
type: "string"
10+
} as const satisfies JSONSchema, (value: string) => value)(stage);
11+
const attempts = lift({
12+
type: "number"
13+
} as const satisfies JSONSchema, {
14+
type: "number"
15+
} as const satisfies JSONSchema, (count: number) => count)(attemptCount);
16+
const accepted = lift({
17+
type: "number"
18+
} as const satisfies JSONSchema, {
19+
type: "number"
20+
} as const satisfies JSONSchema, (count: number) => count)(acceptedCount);
21+
const rejected = lift({
22+
type: "number"
23+
} as const satisfies JSONSchema, {
24+
type: "number"
25+
} as const satisfies JSONSchema, (count: number) => count)(rejectedCount);
26+
const summary = derive({
27+
type: "object",
28+
properties: {
29+
stage: {
30+
type: "string",
31+
asOpaque: true
32+
},
33+
attempts: {
34+
type: "number",
35+
asOpaque: true
36+
},
37+
accepted: {
38+
type: "number",
39+
asOpaque: true
40+
},
41+
rejected: {
42+
type: "number",
43+
asOpaque: true
44+
}
45+
},
46+
required: ["stage", "attempts", "accepted", "rejected"]
47+
} as const satisfies JSONSchema, {
48+
type: "string"
49+
} as const satisfies JSONSchema, {
50+
stage: normalizedStage,
51+
attempts: attempts,
52+
accepted: accepted,
53+
rejected: rejected,
54+
}, (snapshot) => `stage:${snapshot.stage} attempts:${snapshot.attempts}` +
55+
` accepted:${snapshot.accepted} rejected:${snapshot.rejected}`);
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { cell, derive, lift } from "commontools";
2+
3+
const stage = cell<string>("initial");
4+
const attemptCount = cell<number>(0);
5+
const acceptedCount = cell<number>(0);
6+
const rejectedCount = cell<number>(0);
7+
8+
const normalizedStage = lift((value: string) => value)(stage);
9+
const attempts = lift((count: number) => count)(attemptCount);
10+
const accepted = lift((count: number) => count)(acceptedCount);
11+
const rejected = lift((count: number) => count)(rejectedCount);
12+
13+
const summary = derive(
14+
{
15+
stage: normalizedStage,
16+
attempts: attempts,
17+
accepted: accepted,
18+
rejected: rejected,
19+
},
20+
(snapshot) =>
21+
`stage:${snapshot.stage} attempts:${snapshot.attempts}` +
22+
` accepted:${snapshot.accepted} rejected:${snapshot.rejected}`,
23+
);

0 commit comments

Comments
 (0)