11import ts from "typescript" ;
2+ import {
3+ type CellWrapperKind ,
4+ getCellBrand ,
5+ getCellWrapperInfo ,
6+ isCellBrand ,
7+ } from "../typescript/cell-brand.ts" ;
28import type {
39 GenerationContext ,
410 SchemaDefinition ,
@@ -7,7 +13,7 @@ import type {
713import type { SchemaGenerator } from "../schema-generator.ts" ;
814import { detectWrapperViaNode , resolveWrapperNode } from "../type-utils.ts" ;
915
10- type WrapperKind = "Cell" | "Stream" | "OpaqueRef" ;
16+ type WrapperKind = CellWrapperKind ;
1117
1218/**
1319 * Formatter for Common Tools specific types (Cell<T>, Stream<T>, OpaqueRef<T>, Default<T,V>)
@@ -25,12 +31,12 @@ export class CommonToolsFormatter implements TypeFormatter {
2531 }
2632
2733 supportsType ( type : ts . Type , context : GenerationContext ) : boolean {
28- // Check via typeNode for Default (erased at type-level) and all wrapper aliases
34+ // Check via typeNode for Default (erased at type-level)
2935 const wrapperViaNode = detectWrapperViaNode (
3036 context . typeNode ,
3137 context . typeChecker ,
3238 ) ;
33- if ( wrapperViaNode ) {
39+ if ( wrapperViaNode === "Default" ) {
3440 return true ;
3541 }
3642
@@ -39,8 +45,12 @@ export class CommonToolsFormatter implements TypeFormatter {
3945 return true ;
4046 }
4147
48+ if ( ( type . flags & ts . TypeFlags . Union ) !== 0 ) {
49+ return false ;
50+ }
51+
4252 // Check if this is a wrapper type (Cell/Stream/OpaqueRef) via type structure
43- const wrapperInfo = this . getWrapperTypeInfo ( type ) ;
53+ const wrapperInfo = getCellWrapperInfo ( type , context . typeChecker ) ;
4454 return wrapperInfo !== undefined ;
4555 }
4656
@@ -86,61 +96,46 @@ export class CommonToolsFormatter implements TypeFormatter {
8696 }
8797 }
8898
89- // Handle Cell/Stream/OpaqueRef via node (direct or alias)
90- if ( resolvedWrapper && resolvedWrapper . kind !== "Default" ) {
91- // Use the ACTUAL type from the usage site (which has concrete type arguments)
92- const wrapperInfo = this . getWrapperTypeInfo ( type ) ;
93- if ( wrapperInfo ) {
94- // For choosing which node to pass to formatWrapperType:
95- // - If original node has type arguments: use it (has concrete types from usage site)
96- // - If original node is just identifier (alias): use resolved node
97- // formatWrapperType will check if node has type args before extracting inner types
98- const nodeToPass = n && ts . isTypeReferenceNode ( n ) && n . typeArguments
99- ? n // Original has type args, use it
100- : resolvedWrapper . node ; // Original is just alias, use resolved (but won't extract inner types from it)
99+ const wrapperInfo = getCellWrapperInfo ( type , context . typeChecker ) ;
100+ if ( wrapperInfo && ! ( type . flags & ts . TypeFlags . Union ) ) {
101+ const nodeToPass = this . selectWrapperTypeNode (
102+ n ,
103+ resolvedWrapper ,
104+ wrapperInfo . kind ,
105+ ) ;
106+ return this . formatWrapperType (
107+ wrapperInfo . typeRef ,
108+ nodeToPass ,
109+ context ,
110+ wrapperInfo . kind ,
111+ ) ;
112+ }
101113
114+ // If we detected a wrapper syntactically but the current type is wrapped in
115+ // additional layers (e.g., Opaque<OpaqueRef<...>>), recursively unwrap using
116+ // brand information until we reach the underlying wrapper.
117+ const wrapperKinds : WrapperKind [ ] = [ "OpaqueRef" , "Cell" , "Stream" ] ;
118+ for ( const kind of wrapperKinds ) {
119+ const unwrappedType = this . recursivelyUnwrapOpaqueRef (
120+ type ,
121+ kind ,
122+ context . typeChecker ,
123+ ) ;
124+ if ( unwrappedType ) {
125+ const nodeToPass = this . selectWrapperTypeNode (
126+ n ,
127+ resolvedWrapper ,
128+ unwrappedType . kind ,
129+ ) ;
102130 return this . formatWrapperType (
103- wrapperInfo . typeRef ,
131+ unwrappedType . typeRef ,
104132 nodeToPass ,
105133 context ,
106- wrapperInfo . kind ,
134+ unwrappedType . kind ,
107135 ) ;
108- } else {
109- // If we detected a wrapper via typeNode but the type structure is complex
110- // (e.g., wrapped in Opaque union), recursively unwrap to find the base wrapper type
111- const unwrappedType = this . recursivelyUnwrapOpaqueRef (
112- type ,
113- resolvedWrapper . kind ,
114- context . typeChecker ,
115- ) ;
116- if ( unwrappedType ) {
117- const nodeToPass = n && ts . isTypeReferenceNode ( n ) && n . typeArguments
118- ? n
119- : resolvedWrapper . node ;
120-
121- return this . formatWrapperType (
122- unwrappedType . typeRef ,
123- nodeToPass ,
124- context ,
125- unwrappedType . kind ,
126- ) ;
127- }
128- // If we couldn't unwrap, fall through to regular handling
129136 }
130137 }
131138
132- // Fallback: try to get wrapper type information from type structure
133- // (for cases where we don't have a typeNode)
134- const wrapperInfo = this . getWrapperTypeInfo ( type ) ;
135- if ( wrapperInfo ) {
136- return this . formatWrapperType (
137- wrapperInfo . typeRef ,
138- n ,
139- context ,
140- wrapperInfo . kind ,
141- ) ;
142- }
143-
144139 const nodeName = this . getTypeRefIdentifierName ( n ) ;
145140 throw new Error (
146141 `Unexpected CommonTools type: ${ nodeName } ` ,
@@ -219,13 +214,18 @@ export class CommonToolsFormatter implements TypeFormatter {
219214 }
220215
221216 // Cell<T>: disallow Cell<Stream<T>> to avoid ambiguous semantics
222- if ( wrapperKind === "Cell" && this . isStreamType ( innerType ) ) {
217+ if (
218+ wrapperKind === "Cell" &&
219+ this . isStreamType ( innerType , context . typeChecker )
220+ ) {
223221 throw new Error (
224222 "Cell<Stream<T>> is unsupported. Wrap the stream: Cell<{ stream: Stream<T> }>." ,
225223 ) ;
226224 }
227225
228226 // Determine the property name to add based on wrapper kind
227+ // TODO(gideon): Consider updating as[Cell,Opaque,Stream] properties to use an array of brands
228+ // instead of boolean values, to support multiple brands like ["cell", "comparable"]
229229 const propertyName = wrapperKind === "Cell" ? "asCell" : "asOpaque" ;
230230
231231 // Handle case where innerSchema might be boolean (per JSON Schema spec)
@@ -256,9 +256,11 @@ export class CommonToolsFormatter implements TypeFormatter {
256256 }
257257
258258 // Check if this type itself is the target wrapper
259- const wrapperInfo = this . getWrapperTypeInfo ( type ) ;
260- if ( wrapperInfo && wrapperInfo . kind === targetWrapperKind ) {
261- return { type, typeRef : wrapperInfo . typeRef , kind : wrapperInfo . kind } ;
259+ if ( ( type . flags & ts . TypeFlags . Union ) === 0 ) {
260+ const wrapperInfo = getCellWrapperInfo ( type , checker ) ;
261+ if ( wrapperInfo && wrapperInfo . kind === targetWrapperKind ) {
262+ return { type, typeRef : wrapperInfo . typeRef , kind : wrapperInfo . kind } ;
263+ }
262264 }
263265
264266 // If this is a union (e.g., from Opaque<T>), check each member
@@ -363,67 +365,8 @@ export class CommonToolsFormatter implements TypeFormatter {
363365 return { baseType : baseMember } ;
364366 }
365367
366- /**
367- * Check if a type has a CELL_BRAND property (is a cell type)
368- */
369- private isCellType ( type : ts . Type ) : boolean {
370- return type . getProperty ( "CELL_BRAND" ) !== undefined ;
371- }
372-
373- /**
374- * Get the CELL_BRAND string value from a type, if it has one.
375- * Returns the brand string ("opaque", "cell", "stream", etc.) or undefined.
376- */
377- private getCellBrand (
378- type : ts . Type ,
379- checker : ts . TypeChecker ,
380- ) : string | undefined {
381- const brandSymbol = type . getProperty ( "CELL_BRAND" ) ;
382- if ( brandSymbol && brandSymbol . valueDeclaration ) {
383- const brandType = checker . getTypeOfSymbolAtLocation (
384- brandSymbol ,
385- brandSymbol . valueDeclaration ,
386- ) ;
387- if ( brandType . flags & ts . TypeFlags . StringLiteral ) {
388- return ( brandType as ts . StringLiteralType ) . value ;
389- }
390- }
391- return undefined ;
392- }
393-
394- /**
395- * Check if a type is an OpaqueRef type by checking the CELL_BRAND property.
396- * All cell types (OpaqueCell, Cell, Stream) are intersections with CELL_BRAND,
397- * but only OpaqueCell has brand "opaque".
398- */
399368 private isOpaqueRefType ( type : ts . Type , checker : ts . TypeChecker ) : boolean {
400- // Try CELL_BRAND first - most reliable method
401- const brand = this . getCellBrand ( type , checker ) ;
402- if ( brand === "opaque" ) {
403- return true ;
404- }
405-
406- // Fallback: check by constituent interface names for backward compatibility
407- if ( type . flags & ts . TypeFlags . Intersection ) {
408- const intersectionType = type as ts . IntersectionType ;
409- for ( const constituent of intersectionType . types ) {
410- if ( constituent . flags & ts . TypeFlags . Object ) {
411- const objectType = constituent as ts . ObjectType ;
412- if ( objectType . objectFlags & ts . ObjectFlags . Reference ) {
413- const typeRef = objectType as ts . TypeReference ;
414- const name = typeRef . target ?. symbol ?. name ;
415- // Check for OpaqueRef-specific interface names (old and new)
416- if (
417- name === "OpaqueRefMethods" || name === "OpaqueCell" ||
418- name === "IOpaqueCell"
419- ) {
420- return true ;
421- }
422- }
423- }
424- }
425- }
426- return false ;
369+ return isCellBrand ( type , checker , "opaque" ) ;
427370 }
428371
429372 /**
@@ -433,82 +376,36 @@ export class CommonToolsFormatter implements TypeFormatter {
433376 type : ts . Type ,
434377 checker : ts . TypeChecker ,
435378 ) : ts . Type | undefined {
436- if ( ! ( type . flags & ts . TypeFlags . Intersection ) ) {
379+ const wrapperInfo = getCellWrapperInfo ( type , checker ) ;
380+ if ( ! wrapperInfo || wrapperInfo . kind !== "OpaqueRef" ) {
437381 return undefined ;
438382 }
439383
440- const intersectionType = type as ts . IntersectionType ;
441- for ( const constituent of intersectionType . types ) {
442- if ( constituent . flags & ts . TypeFlags . Object ) {
443- const objectType = constituent as ts . ObjectType ;
444- if ( objectType . objectFlags & ts . ObjectFlags . Reference ) {
445- const typeRef = objectType as ts . TypeReference ;
446- const name = typeRef . target ?. symbol ?. name ;
447- // Check for both old (OpaqueRefMethods) and new (OpaqueCell, IOpaqueCell, BrandedCell) names
448- if (
449- name === "OpaqueRefMethods" || name === "OpaqueCell" ||
450- name === "IOpaqueCell" || name === "BrandedCell"
451- ) {
452- // Found wrapper type with type argument, extract T
453- const typeArgs = checker . getTypeArguments ( typeRef ) ;
454- if ( typeArgs && typeArgs . length > 0 ) {
455- return typeArgs [ 0 ] ;
456- }
457- }
458- }
459- }
460- }
461- return undefined ;
384+ const typeArgs = wrapperInfo . typeRef . typeArguments ??
385+ checker . getTypeArguments ( wrapperInfo . typeRef ) ;
386+ return typeArgs && typeArgs . length > 0 ? typeArgs [ 0 ] : undefined ;
462387 }
463388
464- /**
465- * Get wrapper type information (Cell/Stream/OpaqueRef)
466- * Handles both direct references and intersection types (e.g., OpaqueRef<"literal">)
467- * Returns the wrapper kind and the TypeReference needed for formatting
468- */
469- private getWrapperTypeInfo (
470- type : ts . Type ,
471- ) : { kind : WrapperKind ; typeRef : ts . TypeReference } | undefined {
472- // Check direct object type reference
473- if ( type . flags & ts . TypeFlags . Object ) {
474- const objectType = type as ts . ObjectType ;
475- if ( objectType . objectFlags & ts . ObjectFlags . Reference ) {
476- const typeRef = objectType as ts . TypeReference ;
477- const name = typeRef . target ?. symbol ?. name ;
478- if (
479- name === "Cell" || name === "Stream" || name === "OpaqueRef" ||
480- name === "OpaqueCell"
481- ) {
482- // OpaqueCell should be treated as OpaqueRef
483- const kind = name === "OpaqueCell" ? "OpaqueRef" : name ;
484- return { kind, typeRef } ;
485- }
389+ private selectWrapperTypeNode (
390+ originalNode : ts . TypeNode | undefined ,
391+ resolvedWrapper :
392+ | {
393+ kind : "Default" | WrapperKind ;
394+ node : ts . TypeReferenceNode ;
486395 }
396+ | undefined ,
397+ targetKind : WrapperKind ,
398+ ) : ts . TypeReferenceNode | undefined {
399+ if (
400+ originalNode &&
401+ ts . isTypeReferenceNode ( originalNode ) &&
402+ originalNode . typeArguments
403+ ) {
404+ return originalNode ;
487405 }
488-
489- // OpaqueRef/OpaqueCell with literal type arguments becomes an intersection
490- // e.g., OpaqueRef<"initial"> expands to: OpaqueCell<"initial"> & IOpaqueCell<"initial">
491- // We need to detect these internal types to handle this case
492- if ( type . flags & ts . TypeFlags . Intersection ) {
493- const intersectionType = type as ts . IntersectionType ;
494- for ( const constituent of intersectionType . types ) {
495- if ( constituent . flags & ts . TypeFlags . Object ) {
496- const objectType = constituent as ts . ObjectType ;
497- if ( objectType . objectFlags & ts . ObjectFlags . Reference ) {
498- const typeRef = objectType as ts . TypeReference ;
499- const name = typeRef . target ?. symbol ?. name ;
500- // Check for both old (OpaqueRefMethods) and new (OpaqueCell, IOpaqueCell) internal types
501- if (
502- name === "OpaqueRefMethods" || name === "OpaqueCell" ||
503- name === "IOpaqueCell"
504- ) {
505- return { kind : "OpaqueRef" , typeRef } ;
506- }
507- }
508- }
509- }
406+ if ( resolvedWrapper ?. kind === targetKind ) {
407+ return resolvedWrapper . node ;
510408 }
511-
512409 return undefined ;
513410 }
514411
@@ -520,10 +417,8 @@ export class CommonToolsFormatter implements TypeFormatter {
520417 return ts . isIdentifier ( tn ) ? tn . text : undefined ;
521418 }
522419
523- private isStreamType ( type : ts . Type ) : boolean {
524- const objectType = type as ts . ObjectType ;
525- return ! ! ( objectType . objectFlags & ts . ObjectFlags . Reference ) &&
526- ( type as ts . TypeReference ) . target ?. symbol ?. name === "Stream" ;
420+ private isStreamType ( type : ts . Type , checker : ts . TypeChecker ) : boolean {
421+ return getCellBrand ( type , checker ) === "stream" ;
527422 }
528423
529424 private formatDefaultType (
0 commit comments