npm.io
0.0.0 • Published 2d agoCLI

@game-infra/valibot-to-csharp

Licence
Version
0.0.0
Deps
1
Size
107 kB
Vulns
0
Weekly
0

@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 a union(...) (a shorthand some schemas use as a literal placeholder), the parser treats it as literal('foo') and notes the substitution in the generated file.
  • Unsupported features: lazy, tuple, intersect, custom transforms, and non-string record keys. Heterogeneous unions with no common discriminator fall back to JsonElement.
  • Nested anonymous objects are named <ParentType><FieldName> to avoid collisions with sibling inline types.
  • DTO becomes Dto in emitted type names (e.g. EventDefinitionDTOSchema becomes EventDefinitionDto).

Extension points

  • New valibot features: add a node kind to src/ir.ts, parse it in src/parser.ts, and teach src/emitter.ts how 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.createSourceFile and friends). TypeScript 7 no longer exposes that API from the typescript package 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.