LazyTools

🔒 Every tool runs in your browser — the files and values you enter are never uploaded to any server. How it works

explainer

Generate Typed Models from JSON: TypeScript, Go, Python, Rust & C#

By the LazyTools team · Published 2026-08-01 · Updated 2026-08-23 · 6 min read

One JSON sample mapping to typed models in TypeScript, Go, Python, Rust and C#

A JSON sample describes a shape, and that one shape maps to a typed model in any language: an object becomes a struct/class/interface, each key becomes a typed field, nested objects become their own types, and arrays become typed lists. Once you see the mapping, generating a TypeScript interface, a Go struct, a Python dataclass, a Rust serde struct, or a C# class from JSON is the same job with different syntax. Paste your JSON into the converter for your language — TypeScript, Go, Python, Rust or C# — and it runs entirely in your browser.

The same shape, five languages

Take one JSON object:

{ "id": 42, "user_name": "ada", "is_active": true, "scores": [10, 20] }

Here’s the field-type mapping each converter applies:

JSON valueTypeScriptGoPythonRustC#
42 (whole)numberintinti64int
1.5 (decimal)numberfloat64floatf64double
"ada"stringstringstrStringstring
truebooleanboolboolboolbool
[10, 20]number[][]intList[int]Vec<i64>List<int>
{ … }nested interfacenested structnested @dataclassnested structnested class
nullnullinterface{}Optional[Any]Option<Value>object

Same shape in, idiomatic types out.

One JSON shape → five typed models JSON sample { id, name, … } TypeScript interface
<rect x="290" y="320" width="200" height="150" rx="14" fill="#cffafe" stroke="#0891b2" stroke-width="3"/>
<text x="390" y="378" text-anchor="middle" fill="#155e75">Go</text>
<text x="390" y="412" text-anchor="middle" font-size="19" fill="#155e75">struct + tags</text>

<rect x="520" y="320" width="200" height="150" rx="14" fill="#fef9c3" stroke="#ca8a04" stroke-width="3"/>
<text x="620" y="378" text-anchor="middle" fill="#854d0e">Python</text>
<text x="620" y="412" text-anchor="middle" font-size="19" fill="#854d0e">@dataclass</text>

<rect x="750" y="320" width="200" height="150" rx="14" fill="#ffedd5" stroke="#ea580c" stroke-width="3"/>
<text x="850" y="378" text-anchor="middle" fill="#9a3412">Rust</text>
<text x="850" y="412" text-anchor="middle" font-size="19" fill="#9a3412">serde struct</text>

<rect x="980" y="320" width="160" height="150" rx="14" fill="#f3e8ff" stroke="#9333ea" stroke-width="3"/>
<text x="1060" y="378" text-anchor="middle" fill="#6b21a8">C#</text>
<text x="1060" y="412" text-anchor="middle" font-size="19" fill="#6b21a8">class</text>

Field names keep their JSON key via tags / rename / JsonPropertyName attributes

A worked example, side by side

Take the same object again:

{ "id": 42, "user_name": "ada", "is_active": true, "scores": [10, 20] }

Here is what each converter emits. The syntax differs, but the shape — four fields, one of them a list of integers — is identical everywhere.

TypeScript

interface Root {
  id: number;
  user_name: string;
  is_active: boolean;
  scores: number[];
}

Go (note the tags preserving the snake_case keys):

type Root struct {
    ID       int    `json:"id"`
    UserName string `json:"user_name"`
    IsActive bool   `json:"is_active"`
    Scores   []int  `json:"scores"`
}

Python

from dataclasses import dataclass
from typing import List

@dataclass
class Root:
    id: int
    user_name: str
    is_active: bool
    scores: List[int]

Rust (serde derives so it can (de)serialize):

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
struct Root {
    id: i64,
    user_name: String,
    is_active: bool,
    scores: Vec<i64>,
}

C# (with System.Text.Json, add [JsonPropertyName] where the property casing diverges from the key):

using System.Collections.Generic;
using System.Text.Json.Serialization;

public class Root
{
    [JsonPropertyName("id")]        public int Id { get; set; }
    [JsonPropertyName("user_name")] public string UserName { get; set; }
    [JsonPropertyName("is_active")] public bool IsActive { get; set; }
    [JsonPropertyName("scores")]    public List<int> Scores { get; set; }
}

Five files, one mental model. Once you internalise the mapping, switching languages is a change of syntax, not of thinking.

Naming: keeping the JSON key

Languages disagree on casing. Go and C# want PascalCase fields; Rust wants snake_case. When the generated field name differs from the JSON key, a good converter preserves the original key so serialization still round-trips:

  • Go — a struct tag: UserName string \json:“user_name”“
  • Rust — an attribute: #[serde(rename = "userName")]
  • C# — an attribute: [JsonPropertyName("user_name")]
  • Python / TypeScript — the key is usually a valid identifier already, so it’s kept as-is.

That detail is easy to forget by hand and is exactly where mismatches (a field that silently deserializes to its default) come from.

Optional vs required

With a single object, every key present is required. The interesting case is an array of objects where the elements don’t all share the same keys:

[ { "id": 1, "nickname": "a" }, { "id": 2 } ]

Here id is in every element (required) but nickname is not (optional). Converters express that as Optional[...] in Python, Option<...> in Rust, and nullable/? fields elsewhere. Feeding the converter a representative sample — one that includes the optional fields — is what makes this accurate.

Each language spells “this might be absent” differently. When you review the output, this is the column to sanity-check:

LanguageOptional fieldIdiomatic default handling
TypeScriptnickname?: stringundefined when the key is missing
GoNickname *string + json:"nickname,omitempty"nil pointer distinguishes absent from empty
Pythonnickname: Optional[str] = Nonedataclass default of None
Rustnickname: Option<String>serde reads a missing key as None automatically
C#string? Nicknamenull reference (nullable reference types enabled)

A subtle point: in JSON, “the key is missing” and “the key is present but null” are two different states. Go pointers and Rust’s Option can tell them apart; a plain nullable often cannot. If that distinction matters to your API, decide it deliberately rather than accepting the inferred default.

Where to refine the output

Type inference is a scaffold, not a spec. Plan to adjust:

  • Number width — a whole number becomes int/i64; widen to long/u64/float where the data demands.
  • String formats — a date or email is just string; the JSON gives no hint. Add validation separately.
  • Nullability & defaults — decide which optional fields need a default value versus a nullable type.
  • Enums — a field that’s really one of a fixed set is inferred as string; promote it to an enum.

Why generate them in the browser

The JSON you paste is usually a real API response — sometimes from an internal or authenticated endpoint. A generator that uploads it to a server has seen that payload. Every LazyTools converter — TypeScript, Go, Python, Rust, C# — parses and generates entirely in your browser, so nothing leaves your device and it all works offline.

The bottom line

Generating typed models from JSON is one mapping — object → type, key → typed field, nested → nested type, array → typed list, “missing sometimes” → optional — expressed in whichever language you’re working in. Start from a representative sample, let the converter write the boilerplate, and spend your time on the parts inference can’t see: formats, ranges, enums and true nullability.

Frequently asked questions

How do I generate typed models from a JSON sample?

Paste a representative JSON example into a converter for your language: it infers each field's type, turns nested objects into their own types, and marks fields that aren't always present as optional. LazyTools has one for each language — TypeScript interfaces, Go structs, Python dataclasses, Rust serde structs, and C# classes — and each runs entirely in your browser.

How are numbers typed when generating models from JSON?

JSON has a single number type, so converters infer from the value: a whole number becomes an integer type (int, i64) and a number with a decimal point becomes a floating type (float, float64, f64, double). Because inference only sees your sample, widen the type by hand when a field can be fractional or exceed 32 bits.

How do converters decide which fields are optional?

For a single object, every key present is treated as required. For an array of objects, a field is optional if it's missing from any element — those become Optional[...] in Python, Option<...> in Rust, nullable/`?` in others. A field that's always present is required.

Do the generated models keep the original JSON key names?

Yes. Where a language renames a field to its own convention — Go and C# PascalCase, Rust snake_case — the converter adds a tag or attribute that preserves the original key: a Go json:"key" tag, a Rust #[serde(rename="key")] attribute, or a C# [JsonPropertyName("key")] attribute, so (de)serialization still matches the JSON.

Are generated models ready to use as-is?

They're a strong scaffold, not a final contract. Inference can't see string formats (dates, emails), numeric ranges, enums, or which fields are truly optional beyond your sample. Feed the converter a rich example covering optional fields, then tighten types, nullability and names by hand.

Is my JSON uploaded to generate the models?

Not with the LazyTools converters. Every one runs entirely in your browser using the File and JSON APIs, so your data — which is often a real API response — never leaves your device, and they work offline.