Skip to content

Commit f39b87f

Browse files
committed
handle certain kinds of union types more correctly
1 parent 6d92e2d commit f39b87f

File tree

6 files changed

+157
-5
lines changed

6 files changed

+157
-5
lines changed

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ export class CommonToolsFormatter implements TypeFormatter {
4545
return true;
4646
}
4747

48+
if ((type.flags & ts.TypeFlags.Union) !== 0) {
49+
return false;
50+
}
51+
4852
// Check if this is a wrapper type (Cell/Stream/OpaqueRef) via type structure
4953
const wrapperInfo = getCellWrapperInfo(type, context.typeChecker);
5054
return wrapperInfo !== undefined;
@@ -93,7 +97,7 @@ export class CommonToolsFormatter implements TypeFormatter {
9397
}
9498

9599
const wrapperInfo = getCellWrapperInfo(type, context.typeChecker);
96-
if (wrapperInfo) {
100+
if (wrapperInfo && !(type.flags & ts.TypeFlags.Union)) {
97101
const nodeToPass = this.selectWrapperTypeNode(
98102
n,
99103
resolvedWrapper,
@@ -250,9 +254,11 @@ export class CommonToolsFormatter implements TypeFormatter {
250254
}
251255

252256
// Check if this type itself is the target wrapper
253-
const wrapperInfo = getCellWrapperInfo(type, checker);
254-
if (wrapperInfo && wrapperInfo.kind === targetWrapperKind) {
255-
return { type, typeRef: wrapperInfo.typeRef, kind: wrapperInfo.kind };
257+
if ((type.flags & ts.TypeFlags.Union) === 0) {
258+
const wrapperInfo = getCellWrapperInfo(type, checker);
259+
if (wrapperInfo && wrapperInfo.kind === targetWrapperKind) {
260+
return { type, typeRef: wrapperInfo.typeRef, kind: wrapperInfo.kind };
261+
}
256262
}
257263

258264
// If this is a union (e.g., from Opaque<T>), check each member
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"$defs": {
3+
"MyCell": {
4+
"asCell": true,
5+
"type": "number"
6+
},
7+
"NullableCell": {
8+
"anyOf": [
9+
{
10+
"type": "null"
11+
},
12+
{
13+
"$ref": "#/$defs/MyCell"
14+
}
15+
]
16+
},
17+
"OptionalCell": {
18+
"anyOf": [
19+
{
20+
"$ref": "#/$defs/MyCell"
21+
}
22+
]
23+
}
24+
},
25+
"properties": {
26+
"maybeCell": {
27+
"$ref": "#/$defs/OptionalCell"
28+
},
29+
"nullableCell": {
30+
"$ref": "#/$defs/NullableCell"
31+
}
32+
},
33+
"required": [
34+
"maybeCell",
35+
"nullableCell"
36+
],
37+
"type": "object"
38+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
interface MyCell<T> extends BrandedCell<T, "cell"> {}
2+
type OptionalCell = MyCell<number> | undefined;
3+
type NullableCell = MyCell<string> | null;
4+
5+
interface SchemaRoot {
6+
maybeCell: OptionalCell;
7+
nullableCell: NullableCell;
8+
}

packages/utils/deno.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@commontools/utils",
33
"tasks": {
4-
"test": "deno test"
4+
"test": "deno test --allow-env"
55
},
66
"exports": {
77
".": "./src/index.ts",

packages/utils/src/typescript/cell-brand.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,14 @@ function extractWrapperTypeReference(
199199
}
200200
}
201201

202+
if (type.flags & ts.TypeFlags.Union) {
203+
const unionType = type as ts.UnionType;
204+
for (const member of unionType.types) {
205+
const ref = extractWrapperTypeReference(member, checker, seen);
206+
if (ref) return ref;
207+
}
208+
}
209+
202210
return undefined;
203211
}
204212

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { assert, assertEquals, assertExists } from "@std/assert";
2+
import ts from "typescript";
3+
4+
import { getCellWrapperInfo } from "../src/typescript/cell-brand.ts";
5+
6+
function createProgram(source: string): {
7+
checker: ts.TypeChecker;
8+
sourceFile: ts.SourceFile;
9+
} {
10+
const fileName = "test.ts";
11+
const sourceFile = ts.createSourceFile(
12+
fileName,
13+
source,
14+
ts.ScriptTarget.ES2020,
15+
true,
16+
ts.ScriptKind.TS,
17+
);
18+
19+
const compilerOptions: ts.CompilerOptions = {
20+
target: ts.ScriptTarget.ES2020,
21+
module: ts.ModuleKind.ESNext,
22+
strict: true,
23+
};
24+
25+
const host = ts.createCompilerHost(compilerOptions, true);
26+
27+
host.getSourceFile = (name) => name === fileName ? sourceFile : undefined;
28+
host.readFile = (name) => name === fileName ? source : undefined;
29+
host.fileExists = (name) => name === fileName;
30+
host.getDirectories = () => [];
31+
host.getCurrentDirectory = () => "/";
32+
host.writeFile = () => {};
33+
34+
const program = ts.createProgram([fileName], compilerOptions, host);
35+
return { checker: program.getTypeChecker(), sourceFile };
36+
}
37+
38+
function getPropertyType(
39+
checker: ts.TypeChecker,
40+
sourceFile: ts.SourceFile,
41+
interfaceName: string,
42+
propertyName: string,
43+
): ts.Type {
44+
const statements = sourceFile.statements.filter(ts.isInterfaceDeclaration);
45+
const iface = statements.find((stmt) => stmt.name.text === interfaceName);
46+
assertExists(iface, `Interface ${interfaceName} not found`);
47+
48+
const property = iface.members.find((member): member is ts.PropertySignature =>
49+
ts.isPropertySignature(member) &&
50+
!!member.name &&
51+
ts.isIdentifier(member.name) &&
52+
member.name.text === propertyName
53+
);
54+
assertExists(property, `Property ${propertyName} not found`);
55+
56+
return checker.getTypeAtLocation(property);
57+
}
58+
59+
Deno.test("getCellWrapperInfo handles unions containing branded cells", () => {
60+
const source = `
61+
declare const CELL_BRAND: unique symbol;
62+
interface BrandedCell<T, Brand extends string> {
63+
readonly [CELL_BRAND]: Brand;
64+
}
65+
interface Cell<T> extends BrandedCell<T, "cell"> {}
66+
type MaybeCell = Cell<number> | undefined;
67+
type NullableCell = Cell<string> | null;
68+
69+
interface Schema {
70+
maybe: MaybeCell;
71+
nullable: NullableCell;
72+
}
73+
`;
74+
75+
const { checker, sourceFile } = createProgram(source);
76+
const maybeType = getPropertyType(checker, sourceFile, "Schema", "maybe");
77+
const nullableType = getPropertyType(checker, sourceFile, "Schema", "nullable");
78+
79+
const maybeInfo = getCellWrapperInfo(maybeType, checker);
80+
assertExists(maybeInfo, "Expected wrapper info for MaybeCell");
81+
assertEquals(maybeInfo.kind, "Cell");
82+
const maybeArg = maybeInfo.typeRef.typeArguments?.[0];
83+
assertExists(maybeArg);
84+
assertEquals(checker.typeToString(maybeArg), "number");
85+
86+
const nullableInfo = getCellWrapperInfo(nullableType, checker);
87+
assertExists(nullableInfo, "Expected wrapper info for NullableCell");
88+
assertEquals(nullableInfo.kind, "Cell");
89+
const nullableArg = nullableInfo.typeRef.typeArguments?.[0];
90+
assertExists(nullableArg);
91+
assertEquals(checker.typeToString(nullableArg), "string");
92+
});

0 commit comments

Comments
 (0)