minijson

package module
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 14, 2026 License: GPL-3.0 Imports: 12 Imported by: 0

README

minijson

Compact, schema-aware JSON for Go backends and browser frontends.

minijson writes struct field names once per payload, then encodes records as positional arrays. It keeps JSON's inspectability while avoiding repeated object keys in large, similarly shaped responses. Zero-valued fields are omitted, nested structs/slices/maps are supported, and each payload carries the metadata needed by the bundled TypeScript decoder.

ordinary JSON: [{"name":"Ada","age":36},{"name":"Lin"}]
minijson:      [<field metadata>,<compact positional content>]

The Go package provides encoding and typed decoding. The @ivanjoz/minijson package provides frontend decoding into ordinary JavaScript objects.

Install

Go:

go get github.com/ivanjoz/minijson@v0.3.0

TypeScript from GitHub:

bun add @ivanjoz/minijson@github:ivanjoz/minijson#v0.3.0

Go usage

package main

import (
	"fmt"
	"github.com/ivanjoz/minijson"
)

type User struct {
	Name string   `json:"name"`
	Age  int      `json:"age,omitempty"`
	Tags []string `json:"tags,omitempty"`
}

func main() {
	users := []User{{Name: "Ada", Age: 36}, {Name: "Lin", Tags: []string{"admin"}}}
	payload, err := minijson.Marshal(users)
	if err != nil {
		panic(err)
	}

	var decoded []User
	if err := minijson.Unmarshal(payload, &decoded); err != nil {
		panic(err)
	}
	fmt.Println(decoded)
}

Marshal respects exported fields and json tags, including json:"-". Unmarshal expects a non-nil pointer to the target value.

Browser usage

Parse the HTTP response as JSON, then pass the resulting value to unmarshal:

import { unmarshal } from '@ivanjoz/minijson'

const response = await fetch('/api/users')
const users = unmarshal(await response.json())

Values that are not minijson's two-element payload shape pass through unchanged, so the decoder can be used as a general response transform.

Wire format

Every payload is [keys, content]:

  • keys describes each struct type as [typeID, position, fieldName, ...].
  • content contains positional values.
  • Header 1 starts a struct with a type ID; header 0 reuses the previous type.
  • Header 2 marks a slice/array; header 3 marks alternating map keys and values.
  • Struct reference blocks carry skipped positions for zero-valued fields.

The format is self-describing for the TypeScript decoder. Go decoding uses the destination's concrete struct types to restore typed values.

Zero values and MINIJSON_INCLUDE_EMPTY

Zero values never travel, whether or not the field's json tag has omitempty. Go's Unmarshal hides that because it repopulates each field from the destination struct, but a decoder without types cannot: the field is simply absent, so 0, "" and false read back as undefined in the browser.

Set MINIJSON_INCLUDE_EMPTY=1 (or call minijson.SetIncludeEmpty(true)) and each type's keys entry gains a trailing object describing the zero value of its non-omitempty fields:

[1, 0, "id", 1, "codigo", {"id":0,"codigo":"","cantidad":0,"activo":false}]

The TypeScript decoder uses it to reconstruct the exact object encoding/json would have produced. Fields tagged omitempty are left out on purpose — encoding/json would have dropped them too.

The block costs one object per type per payload, not per record: 2000 records of a 10-field struct grow by 114 bytes. It is appended last and is the only object in an entry, and both header parsers read (position, name) pairs and stop at the last complete one, so a decoder that predates the block ignores it. Old and new clients can therefore be mixed while the flag is switched.

Agreement with encoding/json

The contract is that a value produces the same JSON value through Marshal as it does through encoding/json. TestDifferentialAgainstEncodingJSON enforces it: it decodes each payload with no type information — the way the browser does — and diffs the result against json.Marshal. Add a case there whenever a new kind of field appears in a response type.

That contract covers:

  • Embedded structs are promoted. An anonymous field without an explicit json name has its fields flattened into the parent, including transitively and through embedded pointers, with encoding/json's shadowing rules (shallower wins; ties at the same depth resolve only if exactly one is tagged).
  • json.Marshaler and encoding.TextMarshaler are honoured, along with the matching unmarshalers, so a time.Time travels as "2026-08-13T10:00:00Z".
  • Unexported fields are skipped.
Known limitations

Only value-receiver MarshalJSON/MarshalText are honoured. A pointer-receiver marshaler on a non-addressable value is invisible to reflection, and Marshal receives its input through an any, so nothing is addressable at the top level. time.Time, decimal.Decimal and the null.* family all use value receivers.

Development

go test -race ./...
bun test javascript
go test -bench . -benchmem

License

GPL-3.0-only. See LICENSE.

Documentation

Overview

Example
package main

import (
	"fmt"

	"github.com/ivanjoz/minijson"
)

func main() {
	type user struct {
		Name string   `json:"name"`
		Age  int      `json:"age,omitempty"`
		Tags []string `json:"tags,omitempty"`
	}

	users := []user{{Name: "Ada", Age: 36}, {Name: "Lin", Tags: []string{"admin"}}}
	encoded, _ := minijson.Marshal(users)

	var decoded []user
	_ = minijson.Unmarshal(encoded, &decoded)
	fmt.Println(decoded[0].Name, decoded[1].Tags[0])
}
Output:
Ada admin

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func IncludeEmpty added in v0.2.0

func IncludeEmpty() bool

IncludeEmpty reports whether payloads carry the per-type defaults block. Defaults to the MINIJSON_INCLUDE_EMPTY=1 environment variable so it can be switched without a code change.

func Marshal

func Marshal(v any) ([]byte, error)

Marshal converts an object to the compact array format. Returns a single array with two elements: [keys, content]

  • keys: type definitions mapping emit position -> field name (JSON tags)
  • content: the serialized data, fields in emit order, zero values skipped

Single pass, written straight to bytes. The field emit order comes from the registry, frozen the first time a type is encountered and reused for every payload after that; the order travels with each payload in the keys header, so the decoder never needs to share the encoder's history. Field usage is tracked per call on the Encoder, and the keys header is rendered afterwards from it — which works because keys are prepended to the output.

func SetIncludeEmpty added in v0.2.0

func SetIncludeEmpty(enabled bool)

SetIncludeEmpty overrides the environment default. Takes effect on the next Marshal.

func Unmarshal

func Unmarshal(data []byte, v any) error

Unmarshal converts the custom format back to an object Expects format: [keys, content] where keys is the type definitions and content is the data

Types

type Decoder

type Decoder struct {
	// contains filtered or unexported fields
}

func NewDecoder

func NewDecoder() *Decoder

func (*Decoder) Unmarshal

func (d *Decoder) Unmarshal(data any, v any) error

type Encoder

type Encoder struct {
	// contains filtered or unexported fields
}

func NewEncoder

func NewEncoder() *Encoder

type FieldInfo

type FieldInfo struct {
	Index   int    // Position in TypeInfo.Fields, i.e. this field's own index
	Name    string // JSON tag name or field name
	RawName string // Original field name (without JSON tag)
	// OmitEmpty reports whether the json tag carries the omitempty option. It does not change
	// what is written to the wire — zero values are always omitted — it selects which fields get
	// an entry in the keys header's defaults block. See defaults.go.
	OmitEmpty bool
	// IndexPath locates the field inside the type: length 1 for a directly declared field, longer
	// for one promoted out of an embedded struct. Walk it with fieldByIndexPath.
	IndexPath []int
	// Type is the field's own type, cached so decoding never has to re-resolve it per record.
	Type reflect.Type
	// contains filtered or unexported fields
}

FieldInfo stores metadata for one serializable field of a type.

"Serializable" means the field survived promotion: unexported fields and `json:"-"` fields are absent from TypeInfo.Fields entirely, and the fields of an anonymous embedded struct appear here in their own right rather than under the embedded type's name. See fields.go.

type FieldRegistry

type FieldRegistry struct {
	// contains filtered or unexported fields
}

FieldRegistry manages the mapping between types and their IDs

func NewFieldRegistry

func NewFieldRegistry() *FieldRegistry

func (*FieldRegistry) GetID

func (r *FieldRegistry) GetID(t reflect.Type) int

func (*FieldRegistry) GetType

func (r *FieldRegistry) GetType(id int) reflect.Type

func (*FieldRegistry) GetTypeInfo

func (r *FieldRegistry) GetTypeInfo(id int) *TypeInfo

type TypeInfo

type TypeInfo struct {
	ID   int
	Type reflect.Type
	// Fields are the type's serializable fields, with embedded structs promoted. Every index used
	// anywhere else — DefaultOrder, OptimizedOrder, the usage mask, the keys header — indexes into
	// this slice, not into the Go struct's own fields.
	Fields []FieldInfo
	// DefaultOrder lists the field indices in declaration order. Precomputed so a type that has
	// not been optimized yet does not rebuild this slice for every record.
	DefaultOrder []int
	// DefaultsBlock is the rendered {"field":<zero>,...} object for this type's non-omitempty
	// fields, appended to the type's keys header entry when IncludeEmpty is on. Nil when the
	// type has no such field. Built here so emitting it never costs more than a byte copy.
	DefaultsBlock []byte
	// OptimizedOrder / IsOptimized are written once under the registry lock and read-only after.
	OptimizedOrder []int
	IsOptimized    bool
}

TypeInfo stores metadata for a registered type.

Everything here is derived from the Go type and is therefore immutable once built — it is shared across every concurrent Marshal. Per-response state (which fields a given payload actually used) lives on the Encoder instead; see encoderTypeUsage.

OptimizedOrder is the one field that is learned rather than derived. It is frozen from the first payload that carries the type and reused verbatim afterwards, which is what lets Marshal run a single pass. Freezing is safe because the order is transmitted in the payload's keys header on every response, so the decoder never depends on the encoder's history.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL