Skip to content

Feat/support gleam lang - #3529

Open
ocapmycap wants to merge 15 commits into
glideapps:masterfrom
ocapmycap:feat/support-gleam-lang
Open

ocapmycap wants to merge 15 commits into
glideapps:masterfrom
ocapmycap:feat/support-gleam-lang

Conversation

@ocapmycap

@ocapmycap ocapmycap commented Sep 19, 2026

Copy link
Copy Markdown

What changed

quicktype can now generate ✨Gleam! Given JSON samples or a JSON Schema, it emits custom types, encoders built on gleam/json, and decoders built on gleam/dynamic/decode that round-trip the input. Use --lang gleam from the CLI or gleam from the library.

Why

Gleam has to_json and decode generators for types written in Gleam, but it cannot generate types based on valid JSON or JSON Schema.
Gleam is also the first quicktype target whose Int and Float are disjoint at runtime.
Per existing convention, quicktype will collapse heterogenous JSON numbers to the least precise version in the language. As an example, quicktype takes thise JSON:

[{"bar":123},{"bar":12.3}]

And converts it into, for instance, this Rust:

pub type Foo = Vec<FooElement>;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FooElement {
    pub bar: f64, // numbers collapse into f64
}

Widening a whole number to a float is a wrong answer for Gleam, not a cosmetic one, and that shaped the one core change in this PR.
When running the Gleam generator against that same JSON, we are able to produce this:

/// Note this **is** Gleam code, Rust syntax highlighting is the closest thing we have
pub type FooElement {
  FooElement(bar: Bar)
}

pub type Bar {
  BarDouble(Float)
  BarInteger(Int)
}

pub type Foo =
  List(FooElement)

How it works

Two decisions a reviewer might not udnerstand from just the diff:

Recursion. A decoder that reaches itself has to open with decode.recursive, or the generated module compiles clean and then hangs forever when the decoder is constructed. The guard re-runs the decoder body on every decode, so we emit it only where a cycle is broken:

  • on the classes and unions that `canBreakCycles picks
  • on the JSON value decoder, which is always self-recursive.

Numbers. A new TargetLanguage hook, infersUnionsWithBothNumberTypes, lets a language ask inference to keep [1, 1.5] as Int | Float instead of collapsing it to Float. It is off for every existing language, so their output does not change. Gleam turns it on, and a new priority sample, int-float-union.json, covers the case.

What to check

  • The inference hook, since it touches Run.ts, Inference.ts, and Inputs.ts on the path every language takes. Run npm run test:unit locally and confirm in CI that no non-Gleam fixture produces different output.

Risk

Gleam has two runtimes, Erlang and JavaScript, and each treats Int and Float differently. The types generated by this addition do not behave the same at runtime, but a round trip result will always match its source.

Examples

Gist

AI Disclosure

Paired on this with Claude Opus 4.8, 5, and Fable 5.1.

Create packages/quicktype-core/src/language/Gleam/ with index, language,
GleamRenderer, constants, and utils modules mirroring the Crystal sibling.
Register GleamTargetLanguage in All.ts and re-export from language/index.ts.

`node dist/index.js --lang gleam` now emits a Gleam module for trivial JSON
input, and `-o Foo.gleam` extension inference resolves to the language.
Populate constants.ts with Gleam's reserved words (keywords used today plus
identifiers reserved for future use) and the imported module aliases. Add
snake_case and PascalCase naming functions in utils.ts and wire the four
namers plus forbiddenNamesForGlobalNamespace into GleamRenderer.
Emit record types, enum types, tagged unions, the recursive JsonValue type
for `any`, and one `<name>_to_json(x) -> json.Json` encoder per type. Optional
and nullable properties collapse to `option.Option`, encoded by omitting the
key on `None`. Declare `supportsOptionalClassProperties` and
`supportsUnionsWithBothNumberTypes` on the target language.

To satisfy `gleam format --check`, add a small Wadler-style pretty-printer
(pretty.ts) that reproduces the formatter's exact 80-column layout, including
list-spread breaking, the `json.object([...])` hug, and the trailing-lambda
break with its one-column slack. Reserve Gleam prelude type and constructor
names and the fixed JsonValue identifiers, and force identifiers to begin with
a letter.

Verified: 53/54 priority+sample JSON inputs and all 88 schema inputs build
with `gleam build` and pass `gleam format --check`. The lone exception,
blns-object, hits a gleam Erlang-backend bug on an adversarial key and is a
step 6 skip candidate.
Emit one `<name>_decoder() -> decode.Decoder(T)` per type using the
`gleam/dynamic/decode` API. Every decoder opens with `use <- decode.recursive`
so a self-referencing decoder cannot loop at construction; a later commit
narrows the guard to cycle breakers. Optional-or-null fields use
`decode.optional_field` with `decode.optional`, and the `JsonValue` catch-all
is emitted last in `decode.one_of`. `number` fields accept an int and widen
it, since Erlang's int and float decoders are disjoint.

Extend the pretty-printer to reproduce `gleam format`'s hug rules for
constructor calls, trailing lambdas, and `one_of`. Add unit tests for the
`decode.recursive` guard and the `json_value_decoder` catch-all ordering.
Add test/fixtures/gleam/: a `main`-named Gleam project whose driver reads a
JSON file path from argv, decodes it into TopLevel with the generated
decoder, re-encodes to stdout, and exits non-zero on decode failure (the whole
assertion for `.fail.json` samples). Commit manifest.toml with pinned
dependency versions so a Hex release cannot break an unrelated PR, and
gitignore the build/ directory.

Verified end-to-end against a copied fixture project: `gleam deps download`,
`gleam build`, `gleam format --check src/`, a round-trip run that reproduces
pokedex, and a non-zero exit on an expected-failure sample.
Register GleamLanguage in test/languages.ts and add JSONFixture and
JSONSchemaFixture entries in test/fixtures.ts (name "gleam" / "schema-gleam").

Work around a gleam-compiler flake: its BEAM backend segfaults at a low rate
when compiling large generated modules, much more often when many fixture
workers compile in parallel. Precompile dependencies in setup against a
committed placeholder src/quicktype.gleam, and serialize each per-sample
`gleam build` with a shared flock, so module compilations never overlap.

Skip lists, each justified inline:
- skipJSON: keywords/nst-test-suite (module too large — gleam segfaults even
  single-process), blns-object (Erlang backend miscompiles a tab+emoji key),
  nbl-stats (gleam crashes on this module a few percent of the time even for
  an isolated build).
- skipSchema: keyword-unions (~17k-line module the compiler segfaults on).
- skipDiffViaSchema: bug427/github-events/recursive (JSON vs JSON-Schema paths
  name types/fields/enum cases differently; output is correct either way).

Fix two decoder-strictness bugs surfaced by expected-failure samples: an
optional (absent-allowed) non-nullable field must still reject an explicit
null, and a property-less record must require a JSON object rather than
accepting any value.

Verified: `QUICKTEST=true FIXTURE=gleam,schema-gleam npm run test:fixtures`
passes across four consecutive runs.
Add a gleam,schema-gleam row to the fixture matrix in test-pr.yaml and an
"Install Gleam" step guarded by contains(matrix.fixture, 'gleam'), using
erlef/setup-beam pinned to OTP 27 (required by gleam_json's OTP json backend)
and gleam 1.14.0, mirroring the Elixir install step.
Add Gleam to the target-language table in README.md and to the supports line
in packages/quicktype-vscode/README.md.
A record with no properties gets an encoder whose argument is never
read, so Gleam warned about an unused `value`. The encoder now names
that parameter `_value` when the record has no required or optional
fields.
The Gleam compiler segfaults at a low rate on large generated modules
under parallel fixture load. The compile command now retries
`gleam build` up to five times inside the existing lock, since a fresh
compile of the same module almost always succeeds.
Every emitted decoder used to open with `decode.recursive`, which
re-runs the body on each decode. Now only classes and unions chosen as
cycle breakers get the guard, and the JsonValue decoder keeps it because
it is directly self-recursive. Enums and containers never need one.
Adds an opt-in `infersUnionsWithBothNumberTypes` flag on
`TargetLanguage`. When set together with
`supportsUnionsWithBothNumberTypes`, JSON inference keeps `[1, 1.5]` as
`integer | double` instead of widening to `double`. Off by default; no
language changes behavior yet.
Gleam opts into the new flag so mixed samples infer `Int | Float` rather
than re-encoding `123` as `123.0`. Number unions try `decode.int` before
strict `decode.float`, `gleam/int` is imported only when the lenient
number decoder is used, and the fixture round-trips schema-path code
instead of diffing it.
Not a tab-and-emoji key: a 280-char key makes a 270-char Erlang
variable, past the 255-char atom cap. `gleam check` passes, so the
fault is the Erlang backend. A 270-char synthetic key reproduces it.

https://www.erlang.org/doc/system/system_limits.html
Deleted any superfluous code comment that re-described something
obvious in the code itself. Comments recording external constraints
stay.

@lpil lpil left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello! I'm the creator and lead maintainer of Gleam. Really cool to see this pull request, I've long thought it would be great to have Gleam support in Quicktype.

I've left one note inline about the code that generated which I think is important to resolve.

Rust syntax highlighting is the closest thing we have

GitHub has had Gleam syntax highlighting for 4-5 years now.

Int vs float consideration

Gleam is also the first quicktype target whose Int and Float are disjoint at runtime.

This is not true. Many of the supported languages do, including but not limited to Rust, Haskell, Python, Ruby, C, Elixir, Erlang, and Go.

Widening a whole number to a float is a wrong answer for Gleam, not a cosmetic one, and that shaped the one core change in this PR.

Why do you say this is the case? Rust goes much further than Gleam does in terms of splitting out its number types, so I'm surprised that using a float is considered an appropriate approach for Rust but not Gleam.

Gleam has two runtimes, Erlang and JavaScript, and each treats Int and Float differently. The types generated by this addition do not behave the same at runtime, but a round trip result will always match its source.

Generated decoders should take this into account as JSON does not have distinct integer and float types.

Comment on lines +568 to +572
private emitTopLevelAlias(t: Type, name: Name): void {
// `gleam format` always places an aliased type on its own line.
this.line(0, `pub type ${this.nameToString(name)} =`);
this.emitDoc(2, this.typeDoc(t));
}

@lpil lpil Sep 19, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use of aliases in this is considered an anti-pattern and is highly discouraged and may be deprecated in future. I think it is important that quicktype doesn't encode this pattern that we want to spread.

Instead in Gleam we would write the actual type without obscuring it with an alias. This is clearer and avoids the previously common problem of misunderstanding the semantics of aliases.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants