Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion docs/specs/language-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ union Shape {

Unions render visually distinct from records: dashed dividers between variants and a `|` pipe prefix on each variant row.

Variants can also pin an explicit numeric discriminant:

```
union ErrorCode {
ParseError = -32700
InvalidRequest = -32600
MethodNotFound = -32601
}
```

This is useful when the integer value is part of a wire contract, such as protocol error codes or FFI enums.

### Generic unions

```
Expand Down Expand Up @@ -147,10 +159,11 @@ Record = "type" Name Generics? "{" Field* "}"
Union = "union" Name Generics? "{" Variant* "}"
Alias = "alias" Name Generics? "=" TypeRef
Field = Name ":" TypeRef
Variant = Name ("{" Field* "}")?
Variant = Name ("=" Number)? ("{" Field* "}")?
TypeRef = Name ("<" TypeRef ("," TypeRef)* ">")?
Generics = "<" Name ("," Name)* ">"
Name = [A-Za-z_][A-Za-z0-9_]*
Number = "-"? [0-9] ([0-9_]* [0-9])?
```

The grammar is LL(1) with ~6 productions. Newlines and commas both work as separators inside `{ }` blocks.
10 changes: 10 additions & 0 deletions packages/typediagram/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ Three constructs:

Generics with `<T>`. Comments with `#`.

Explicit numeric discriminants are supported on union variants:

```td
union ErrorCode {
ParseError = -32700
InvalidRequest = -32600
MethodNotFound = -32601
}
```

## Subpath exports

- `typediagram-core` — high-level `parse`, `layout`, `renderSvg`
Expand Down
5 changes: 4 additions & 1 deletion packages/typediagram/scripts/bundle-size.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
// budget. Uses esbuild to tree-shake and measure the output size.
// Budget was 50 KB with 6 converters. Dart + Protobuf converters added
// ~8-10 KB each of parser/emitter logic, so the budget was raised to 75 KB.
// Tuple variants, explicit discriminants, and untagged unions pushed the
// minified core slightly higher, and declaration-level target gating added
// parser + emitter filtering overhead, so the current budget is 80 KB.
import { build } from "esbuild";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";

const here = dirname(fileURLToPath(import.meta.url));
const entry = resolve(here, "..", "src", "index.ts");
const BUDGET_KB = 75.5;
const BUDGET_KB = 80;

const result = await build({
entryPoints: [entry],
Expand Down
4 changes: 2 additions & 2 deletions packages/typediagram/src/converters/csharp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
// Option<T> <-> T?. Alias <-> `using X = Y;`.
import type { Diagnostic } from "../parser/diagnostics.js";
import { type Result, err } from "../result.js";
import type { Model, ResolvedTypeRef } from "../model/types.js";
import { type Model, type ResolvedTypeRef, visibleDeclsForTarget } from "../model/types.js";
import { ModelBuilder, record, union, alias } from "../model/builder.js";
import type { Converter } from "./types.js";
import { parseTypeRef } from "./parse-typeref.js";
Expand Down Expand Up @@ -325,7 +325,7 @@ const toCSharp = (model: Model): string => {
// file scope wherever it appears, so keeping aliases in source order
// preserves round-trip order at the cost of the more idiomatic
// "usings-at-top" layout.
for (const d of model.decls) {
for (const d of visibleDeclsForTarget(model.decls, "csharp")) {
if (d.kind === "record") {
lines.push(...emitRecord(d.name, d.fields, d.generics), "");
} else if (d.kind === "union") {
Expand Down
4 changes: 2 additions & 2 deletions packages/typediagram/src/converters/dart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
// Generics use Dart's first-class type parameters.
import type { Diagnostic } from "../parser/diagnostics.js";
import { type Result, err } from "../result.js";
import type { Model, ResolvedTypeRef } from "../model/types.js";
import { type Model, type ResolvedTypeRef, visibleDeclsForTarget } from "../model/types.js";
import { ModelBuilder, record, union, alias } from "../model/builder.js";
import type { Converter } from "./types.js";
import { parseTypeRef } from "./parse-typeref.js";
Expand Down Expand Up @@ -362,7 +362,7 @@ const emitAlias = (name: string, target: ResolvedTypeRef, generics: string[]): s

const toDart = (model: Model): string => {
const lines: string[] = [];
for (const d of model.decls) {
for (const d of visibleDeclsForTarget(model.decls, "dart")) {
if (d.kind === "record") {
lines.push(...emitRecord(d.name, d.fields, d.generics), "");
} else if (d.kind === "union") {
Expand Down
4 changes: 2 additions & 2 deletions packages/typediagram/src/converters/fsharp.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// [CONV-FS] F# <-> typeDiagram bidirectional converter.
import type { Diagnostic } from "../parser/diagnostics.js";
import { type Result, err } from "../result.js";
import type { Model, ResolvedTypeRef } from "../model/types.js";
import { type Model, type ResolvedTypeRef, visibleDeclsForTarget } from "../model/types.js";
import { ModelBuilder, record, union, alias } from "../model/builder.js";
import type { Converter } from "./types.js";
import { parseTypeRef } from "./parse-typeref.js";
Expand Down Expand Up @@ -205,7 +205,7 @@ const mapTdToFs = (t: ResolvedTypeRef): string => {
const toFSharp = (model: Model): string => {
const lines: string[] = [];

for (const d of model.decls) {
for (const d of visibleDeclsForTarget(model.decls, "fsharp")) {
const genericsStr = d.generics.length > 0 ? `<${d.generics.map((g) => `'${g}`).join(", ")}>` : "";

if (d.kind === "record") {
Expand Down
4 changes: 2 additions & 2 deletions packages/typediagram/src/converters/go.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
// Option<T> <-> *T. Generics use Go 1.18+ type parameters (`[T any]`).
import type { Diagnostic } from "../parser/diagnostics.js";
import { type Result, err } from "../result.js";
import type { Model, ResolvedTypeRef } from "../model/types.js";
import { type Model, type ResolvedTypeRef, visibleDeclsForTarget } from "../model/types.js";
import { ModelBuilder, record, union, alias } from "../model/builder.js";
import type { Converter } from "./types.js";
import { parseTypeRef } from "./parse-typeref.js";
Expand Down Expand Up @@ -358,7 +358,7 @@ const variantStructName = (unionName: string, variantName: string): string => `$
const toGo = (model: Model): string => {
const lines: string[] = ["package types", ""];

for (const d of model.decls) {
for (const d of visibleDeclsForTarget(model.decls, "go")) {
if (d.kind === "record") {
const gens = goGenericsDecl(d.generics);
lines.push(`type ${d.name}${gens} struct {`);
Expand Down
4 changes: 2 additions & 2 deletions packages/typediagram/src/converters/php.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// [CONV-PHP] PHP DTO <-> typeDiagram bidirectional converter.
import type { Diagnostic } from "../parser/diagnostics.js";
import { type Result, err } from "../result.js";
import type { Model, ResolvedTypeRef } from "../model/types.js";
import { type Model, type ResolvedTypeRef, visibleDeclsForTarget } from "../model/types.js";
import { ModelBuilder, alias, record, union } from "../model/builder.js";
import type { Converter } from "./types.js";
import { parseTypeRef } from "./parse-typeref.js";
Expand Down Expand Up @@ -315,7 +315,7 @@ const renderAlias = (name: string, generics: readonly string[], target: Resolved
};

const toPhp = (model: Model): string => {
const blocks = model.decls.flatMap((decl) => {
const blocks = visibleDeclsForTarget(model.decls, "php").flatMap((decl) => {
if (decl.kind === "record") {
return [renderRecord(decl.name, decl.generics, decl.fields)];
}
Expand Down
11 changes: 3 additions & 8 deletions packages/typediagram/src/converters/protobuf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@
// parser prefers over the proto field type, guaranteeing round-trip.
import type { Diagnostic } from "../parser/diagnostics.js";
import { type Result, err } from "../result.js";
import type { Model, ResolvedTypeRef } from "../model/types.js";
import { type Model, type ResolvedTypeRef, visibleDeclsForTarget } from "../model/types.js";
import { ModelBuilder, record, union, alias } from "../model/builder.js";
import type { Converter } from "./types.js";
import { parseTypeRef, printTypeRef } from "./parse-typeref.js";
import { extractBalancedBlock, splitTopLevelCommas } from "./brace-lang.js";
import { extractBalancedBlock } from "./brace-lang.js";

// ── Type mapping ──

Expand Down Expand Up @@ -434,7 +434,7 @@ const emitUnion = (

const toProto = (model: Model): string => {
const lines: string[] = ['syntax = "proto3";', ""];
for (const d of model.decls) {
for (const d of visibleDeclsForTarget(model.decls, "protobuf")) {
if (d.kind === "record") {
lines.push(...emitGenericsDirective(d.generics, ""));
lines.push(`message ${d.name} {`);
Expand All @@ -452,11 +452,6 @@ const toProto = (model: Model): string => {
return lines.join("\n").replace(/\n+$/, "\n");
};

// splitTopLevelCommas is used via the shared helper when parsing generic
// argument lists inside `@td-type:` directives. Re-export by using the
// import so tree-shaking doesn't complain.
void splitTopLevelCommas;

export const protobuf: Converter = {
language: "protobuf",
fromSource: fromProto,
Expand Down
45 changes: 23 additions & 22 deletions packages/typediagram/src/converters/python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
// convenience emitter for downstream use.
import type { Diagnostic } from "../parser/diagnostics.js";
import { type Result, err } from "../result.js";
import type { Model, ResolvedDecl, ResolvedTypeRef } from "../model/types.js";
import { type Model, type ResolvedDecl, type ResolvedTypeRef, visibleDeclsForTarget } from "../model/types.js";
import { ModelBuilder, record, union, alias } from "../model/builder.js";
import type { Converter, PythonOpts } from "./types.js";
import { parseTypeRef } from "./parse-typeref.js";
Expand Down Expand Up @@ -325,7 +325,7 @@ const declUsesAny = (d: ResolvedDecl): boolean =>
? d.variants.some((v) => v.fields.some((f) => usesAny(f.type)))
: usesAny(d.target);

const modelUsesAny = (model: Model): boolean => model.decls.some(declUsesAny);
const modelUsesAny = (decls: readonly ResolvedDecl[]): boolean => decls.some(declUsesAny);

const mapTdToPyDataclass = (t: ResolvedTypeRef): string => {
const name = TD_TO_PY[t.name] ?? t.name;
Expand Down Expand Up @@ -361,52 +361,52 @@ const pydanticFieldSuffix = (t: ResolvedTypeRef): string =>
? " = Field(default_factory=dict)"
: "";

const needsDataclassField = (model: Model): boolean =>
model.decls.some(
const needsDataclassField = (decls: readonly ResolvedDecl[]): boolean =>
decls.some(
(d) =>
(d.kind === "record" && d.fields.some((f) => isList(f.type) || isMap(f.type))) ||
(d.kind === "union" && d.variants.some((v) => v.fields.some((f) => isList(f.type) || isMap(f.type))))
);

const hasBareEnum = (model: Model): boolean =>
model.decls.some((d) => d.kind === "union" && d.variants.every((v) => v.fields.length === 0));
const hasBareEnum = (decls: readonly ResolvedDecl[]): boolean =>
decls.some((d) => d.kind === "union" && d.variants.every((v) => v.fields.length === 0));

const hasOption = (model: Model): boolean =>
model.decls.some(
const hasOption = (decls: readonly ResolvedDecl[]): boolean =>
decls.some(
(d) =>
(d.kind === "record" && d.fields.some((f) => isOption(f.type))) ||
(d.kind === "union" && d.variants.some((v) => v.fields.some((f) => isOption(f.type)))) ||
(d.kind === "alias" && isOption(d.target))
);

const hasGenerics = (model: Model): boolean => model.decls.some((d) => d.generics.length > 0);
const hasGenerics = (decls: readonly ResolvedDecl[]): boolean => decls.some((d) => d.generics.length > 0);

const buildDataclassImports = (model: Model): string[] => {
const buildDataclassImports = (decls: readonly ResolvedDecl[]): string[] => {
const lines = ["from __future__ import annotations"];
const dataclassImports = ["dataclass"];
if (needsDataclassField(model)) {
if (needsDataclassField(decls)) {
dataclassImports.push("field");
}
lines.push(`from dataclasses import ${dataclassImports.join(", ")}`);
if (hasBareEnum(model)) {
if (hasBareEnum(decls)) {
lines.push("from enum import Enum");
}
const typingNames: string[] = [];
if (hasOption(model)) {
if (hasOption(decls)) {
typingNames.push("Optional");
}
if (modelUsesAny(model)) {
if (modelUsesAny(decls)) {
typingNames.push("Any");
}
if (hasGenerics(model)) {
if (hasGenerics(decls)) {
typingNames.push("Generic", "TypeVar");
}
if (typingNames.length > 0) {
lines.push(`from typing import ${typingNames.join(", ")}`);
}
// Declare the TypeVars used across the model so `Generic[T]` resolves.
const typeVars = new Set<string>();
for (const d of model.decls) {
for (const d of decls) {
for (const g of d.generics) {
typeVars.add(g);
}
Expand All @@ -420,20 +420,20 @@ const buildDataclassImports = (model: Model): string[] => {
return lines;
};

const buildPydanticImports = (model: Model): string[] => {
const buildPydanticImports = (decls: readonly ResolvedDecl[]): string[] => {
const lines = ["from __future__ import annotations", "from pydantic import BaseModel"];
const hasCollections = model.decls.some(
const hasCollections = decls.some(
(d) =>
(d.kind === "record" && d.fields.some((f) => isList(f.type) || isMap(f.type))) ||
(d.kind === "union" && d.variants.some((v) => v.fields.some((f) => isList(f.type) || isMap(f.type))))
);
if (hasCollections) {
lines.push("from pydantic import Field");
}
if (hasBareEnum(model)) {
if (hasBareEnum(decls)) {
lines.push("from enum import Enum");
}
if (modelUsesAny(model)) {
if (modelUsesAny(decls)) {
lines.push("from typing import Any");
}
lines.push("");
Expand Down Expand Up @@ -487,10 +487,11 @@ const emitBareEnum = (name: string, variants: readonly { name: string }[]): stri

const toPython = (model: Model, opts?: PythonOpts): string => {
const pydantic = opts?.style === "pydantic";
const lines: string[] = pydantic ? buildPydanticImports(model) : buildDataclassImports(model);
const decls = visibleDeclsForTarget(model.decls, "python");
const lines: string[] = pydantic ? buildPydanticImports(decls) : buildDataclassImports(decls);
const emitRecord = pydantic ? emitPydanticRecord : emitDataclassRecord;

for (const d of model.decls) {
for (const d of decls) {
if (d.kind === "record") {
lines.push(...emitRecord(d.name, d.fields, d.generics), "");
} else if (d.kind === "union") {
Expand Down
Loading
Loading