@game-infra/valibot-to-csharp
@game-infra/valibot-to-csharp
Generate idiomatic C# record types from TypeScript valibot schemas.
Why
We author JSON schemas in TypeScript (in editors and web tooling) but consume that JSON from a .NET game using System.Text.Json. Keeping a hand-maintained C# mirror of every object({...}) is tedious and drifts the moment someone renames a field. This package walks the valibot source with the TypeScript compiler API and emits a .cs file whose records deserialise the same JSON, so the mirror is never hand-maintained.
Install
This package is private and consumed as source from a sibling game-infra checkout (see docs/CONSUMING.md in the repo root). Add it to your game repo as a linked dependency:
// pnpm
"devDependencies": {
"@game-infra/valibot-to-csharp": "link:../../../game-infra/packages/valibot-to-csharp"
}
// npm
"devDependencies": {
"@game-infra/valibot-to-csharp": "file:../../../game-infra/packages/valibot-to-csharp"
}
CLI
There is no published dist, so run the CLI straight from the sibling checkout's source with a TS runner such as tsx. Plain node src/cli.ts does not work: Node's type stripping refuses to map the .js relative import specifiers this repo uses onto their .ts sources.
npx tsx ../game-infra/packages/valibot-to-csharp/src/cli.ts \
--input ./schemas/events.ts \
--input ./schemas/common.ts \
--output ./Generated \
--namespace MyGame.Events
Alternatively, build once inside game-infra (pnpm --filter @game-infra/valibot-to-csharp build) and run the compiled entry point, which keeps its shebang:
node ../game-infra/packages/valibot-to-csharp/dist/cli.js --input ./schemas/events.ts --output ./Generated --namespace MyGame.Events
Flags:
| Flag | Meaning |
|---|---|
--input, -i |
Repeatable. Path to a .ts file containing valibot schemas. |
--output, -o |
Output directory for generated .cs files. |
--namespace |
C# namespace for emitted types. Defaults to Generated. |
--bundle |
Write a single bundle .cs file with the given name. |
--union-name |
Override a union base name. Format: SchemaConst:CSharpName. |
--help, -h |
Show usage. |
Programmatic
Generate and write files in one call:
import { generateToFile } from "@game-infra/valibot-to-csharp";
await generateToFile({
inputs: ["./schemas/events.ts"],
outputDir: "./Generated",
namespace: "MyGame.Events",
});
Inspect the output without touching disk (handy for embedding into other build pipelines):
import { generate } from "@game-infra/valibot-to-csharp";
const result = await generate({
inputs: ["./schemas/events.ts"],
outputDir: "./Generated",
namespace: "MyGame.Events",
bundle: true,
bundleName: "Events.cs",
});
for (const file of result.files) {
console.log(file.path, file.content.length);
}
Go lower-level with the parser and emitter directly:
import { emitFileHeader, emitModule, parseFiles } from "@game-infra/valibot-to-csharp";
const module = parseFiles(["./schemas/events.ts"]);
const emit = emitModule(module, { namespace: "MyGame.Events" });
const csSource = emitFileHeader(module.notes) + emit.source;
Supported valibot surface
| valibot | C# |
|---|---|
string() |
string |
number() |
double |
boolean() |
bool |
unknown() |
JsonElement |
array(X) |
IReadOnlyList<X> |
record(string(), V) |
IReadOnlyDictionary<string, V> |
object({...}) |
public sealed record |
optional(X) / nullable(X) |
Nullable field (X?) |
literal('a') |
String literal (merged into enums/variant tags) |
picklist(['a','b']) |
[JsonConverter(typeof(JsonStringEnumConverter))] enum |
union([literal(...), ...]) |
Enum |
union([string(), array(string())]) |
StringOrStringList helper (auto-emitted) |
union([{type:'A',...}, ...]) |
[JsonPolymorphic] abstract record with derived records |
variant('type', [...]) |
Same as discriminated union |
pipe(inner, ...validators) |
inner (validators are ignored) |
Cross-file imports (relative paths only) are followed automatically so imported schemas land in the same output file. Imports from node_modules are not traversed.
Caveats
- Not evaluated: the generator parses the source and never executes it. Schemas built at runtime (e.g. produced from a function) are invisible to it.
- String literal kludge: if you write
string('foo')inside aunion(...)(a shorthand some schemas use as a literal placeholder), the parser treats it asliteral('foo')and notes the substitution in the generated file. - Unsupported features:
lazy,tuple,intersect,customtransforms, and non-string record keys. Heterogeneous unions with no common discriminator fall back toJsonElement. - Nested anonymous objects are named
<ParentType><FieldName>to avoid collisions with sibling inline types. DTObecomesDtoin emitted type names (e.g.EventDefinitionDTOSchemabecomesEventDefinitionDto).
Extension points
- New valibot features: add a node kind to
src/ir.ts, parse it insrc/parser.ts, and teachsrc/emitter.tshow to render it. The IR is a closed union, so the compiler points at every switch that needs a new case. - Naming:
--union-name(CLI) /unionRenames(API) override the C# base-type name of a union or variant schema without touching the TS source.
Dependencies
@typescript/typescript6: the classic in-process TypeScript compiler API (ts.createSourceFileand friends). TypeScript 7 no longer exposes that API from thetypescriptpackage root, so the parser uses this compatibility package while the repo compiles with TS 7.
Consumers
The *-schemas contract packages in this repo, and game repos that need C# mirrors of their JSON contracts for .NET clients.