Used in production at Storyden - an open source forum+wiki written in Go!
JSON Schema Code Generator — Generate type-safe code from JSON Schema definitions.
- Multi-language support: Go, TypeScript, (Types, Zod), Java, Python (Pydantic v2)
- Discriminated unions: First-class support for tagged unions with type guards and pattern matching
allOfbase-type composition: Base struct fields are merged into each union variant; a composing schema becomes a transparent type alias- Inheritance via
allOf: Shared base types in discriminated union variants generate proper class inheritance in Python and base structs in Go/Java - Enum value slices: Go generates a
var FooValues = []Foo{...}slice alongside every string/integer enum - Typed additional properties:
additionalPropertieswith a schema generatesmap[string]Tinstead ofmap[string]any - Format mappings: Configurable type mappings for
uuid,date-time,email, and other formats - Config file: Generate multiple languages from a single schema with
schemancer.yaml
I use a ton of code-generation in my workflows, OpenAPI, JSONSchema, Protocol Buffers, etc. I found a lot of the JSONSchema tooling to lack support for a tool I reach for a lot while defining data models: discriminated unions. I also wanted a consistent cross-language tool and output style, Schemancer supports 4 languages.
More details on why, and how this codebase is maintained.
go install github.com/Southclaws/schemancer@latest# Generate Go types
schemancer schema.yaml golang output.go --package=models
# Generate TypeScript types
schemancer schema.yaml typescript output.ts
# Generate Java classes
schemancer schema.yaml java output.java --package=com.example
# Generate Python Pydantic models
schemancer schema.yaml python output.py
# Generate TypeScript Zod schemas
schemancer schema.yaml typescript-zod output.ts
# Output to stdout
schemancer schema.yaml typescript -Create a schemancer.yaml:
golang:
output: "./generated"
package: "models"
typescript:
output: "./generated"
typescript-zod:
output: "./generated"
java:
output: "./generated"
package: "com.example.models"
python:
output: "./generated"Then run:
schemancer schema.yamlThis generates all configured languages in one command.
schemancer has first-class support for discriminated unions (tagged unions). Given a schema like:
$defs:
Event:
oneOf:
- $ref: "#/$defs/CreatedEvent"
- $ref: "#/$defs/UpdatedEvent"
- $ref: "#/$defs/DeletedEvent"
CreatedEvent:
type: object
required: [type, id, name]
properties:
type: { const: "created" }
id: { type: string }
name: { type: string }
UpdatedEvent:
type: object
required: [type, id, changes]
properties:
type: { const: "updated" }
id: { type: string }
changes: { type: object }
DeletedEvent:
type: object
required: [type, id]
properties:
type: { const: "deleted" }
id: { type: string }
reason: { type: string }export interface CreatedEvent {
type: "created";
id: string;
name: string;
}
export interface UpdatedEvent {
type: "updated";
id: string;
changes: Record<string, unknown>;
}
export interface DeletedEvent {
type: "deleted";
id: string;
reason?: string;
}
export type Event = CreatedEvent | UpdatedEvent | DeletedEvent;
export function isCreatedEvent(value: Event): value is CreatedEvent {
return value.type === "created";
}
// ... type guards for each variantimport { z } from "zod";
export const CreatedEventSchema = z.object({
type: z.literal("created"),
id: z.string(),
name: z.string(),
});
export type CreatedEvent = z.infer<typeof CreatedEventSchema>;
export const UpdatedEventSchema = z.object({
type: z.literal("updated"),
id: z.string(),
changes: z.record(z.string(), z.unknown()),
});
export type UpdatedEvent = z.infer<typeof UpdatedEventSchema>;
export const DeletedEventSchema = z.object({
type: z.literal("deleted"),
id: z.string(),
reason: z.string().optional(),
});
export type DeletedEvent = z.infer<typeof DeletedEventSchema>;
export const EventSchema = z.discriminatedUnion("type", [
CreatedEventSchema,
UpdatedEventSchema,
DeletedEventSchema,
]);
export type Event = z.infer<typeof EventSchema>;from typing import Annotated, Literal, Union
from pydantic import BaseModel, Field
class CreatedEvent(BaseModel):
type: Literal["created"]
id: str
name: str
class UpdatedEvent(BaseModel):
type: Literal["updated"]
id: str
changes: dict[str, Any]
class DeletedEvent(BaseModel):
type: Literal["deleted"]
id: str
reason: str | None = None
Event = Annotated[
Union[CreatedEvent, UpdatedEvent, DeletedEvent],
Field(discriminator="type"),
]type Event interface {
EventType() string
isEvent()
}
type EventWrapper struct { Event }
func (w *EventWrapper) UnmarshalJSON(data []byte) error {
// Automatic unmarshaling based on discriminator
}
type CreatedEvent struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
}
func (CreatedEvent) EventType() string { return "created" }
func (CreatedEvent) isEvent() {}
// ... other variants@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = CreatedEvent.class, name = "created"),
@JsonSubTypes.Type(value = UpdatedEvent.class, name = "updated"),
@JsonSubTypes.Type(value = DeletedEvent.class, name = "deleted")
})
public sealed interface Event permits CreatedEvent, UpdatedEvent, DeletedEvent {
String type();
}When a schema composes a base struct with a discriminated union via allOf, the base fields are merged into every union variant and the composing schema becomes a transparent alias:
$defs:
PluginConfigurationFieldSchema:
allOf:
- $ref: "#/$defs/PluginConfigurationFieldBase" # shared fields
- $ref: "#/$defs/PluginConfigurationField" # discriminated union
PluginConfigurationFieldBase:
type: object
properties:
id: { type: string }
label: { type: string }
PluginConfigurationField:
oneOf:
- $ref: "#/$defs/PluginConfigurationFieldString"
- $ref: "#/$defs/PluginConfigurationFieldNumber"
PluginConfigurationFieldString:
type: object
required: [type]
properties:
type: { type: string, const: string }
default: { type: string }
PluginConfigurationFieldNumber:
type: object
required: [type]
properties:
type: { type: string, const: number }
default: { type: number }Generated Go — base fields are merged into each variant, and the composing schema becomes an alias:
type PluginConfigurationFieldString struct {
Default *string `json:"default,omitempty"`
ID *string `json:"id,omitempty"`
Label *string `json:"label,omitempty"`
Type string `json:"type"`
}
type PluginConfigurationFieldNumber struct {
Default *float64 `json:"default,omitempty"`
ID *string `json:"id,omitempty"`
Label *string `json:"label,omitempty"`
Type string `json:"type"`
}
// Transparent alias — PluginConfigurationFieldSchema IS PluginConfigurationField
type PluginConfigurationFieldSchema = PluginConfigurationField| Option | Description |
|---|---|
package |
Package name for generated code |
optional_style |
pointer (default) or opt (uses opt.Optional[T]) |
format_mappings |
Custom type mappings |
| Option | Description |
|---|---|
null_optional |
Use null instead of undefined for optional fields |
branded_primitives |
Use branded types for nominal typing |
format_mappings |
Custom type mappings |
| Option | Description |
|---|---|
format_mappings |
Custom type mappings |
| Option | Description |
|---|---|
package |
Package name for generated code |
format_mappings |
Custom type mappings |
| Option | Description |
|---|---|
format_mappings |
Custom type mappings |
Override how JSON Schema formats map to target types:
golang:
format_mappings:
uuid:
type: "uuid.UUID"
import: "github.com/google/uuid"
date-time:
type: "time.Time"
import: "time"
python:
format_mappings:
uuid:
type: "UUID"
import: "uuid"
email:
type: "EmailStr"
import: "pydantic"While working across many languages that often need to talk to each other over RPC-like transports or perform LLM assisted tool-calls, I often reach for JSON Schema as its a lingua-franca of data structure modelling.
I built this tool primarily to serve the plugin system (which heavily uses JSON-RPC defined in JSON Schema) and the AI Agents features of Storyden which supports plugins written in many languages. So Schemancer was born to easily build SDKs in Go, Python, TypeScript and Java.
This codebase is also somewhat of an experiment of code-generation tooling for LLMs. I've found agentic coding tools really shine when you provide an input and a desired output. As such, I have absolutely no clue how this code works, I simply provided the input schemas and desired output in tests, the clanker did the rest. The only input I had was to nudge the agent to use an intermediate representation (schemancer/ir/ir.go) much like compilers do, to simplify the architecture. Most of the logic is there then once a schema is in IR format, it's fairly trivial to pipe into a template engine to generate code for each language.
Using generated code is also a brilliant grounding tool for working with agents. I frequently write schemas by hand to build the data model I want then use agentic coding tools to utilise the generated code to achieve the goal I want. I've found this workflow to be very effective in enforcing domain boundaries and invariants.
If you would like a new language or a particular feature, simply provide your example schema and your desired output code, the clanker will do the rest.