README

#json

The json package provides comprehensive JSON handling capabilities, including parsing, stringifying, and type-safe conversion between JSON and other MoonBit data types.

Everything revolves around the builtin Json enum (Null · True · False · Number · String · Array · Object), which can also be written as a literal in MoonBit source:

flowchart LR S["text : StringView"] -->|"@json.parse<br/>raise ParseError"| J["Json (builtin enum)"] L["Json literal in source<br/>{ &quot;name&quot;: &quot;Alice&quot;, &quot;tags&quot;: [1, 2] }"] --> J J -->|"@json.from_json (T : FromJson)<br/>raise JsonDecodeError, reports a JsonPath"| T["typed value T"] T -->|"@json.to_json (T : ToJson)"| J J -->|"stringify(escape_slash?, indent?, replacer?)"| O["String"]

#Basic JSON Operations

#Parsing and Validating JSON

///|
test "parse and validate jsons" {
// Check if a string is valid JSON
assert_true(@json.valid("{\"key\": 42}"))
assert_true(@json.valid("[1, 2, 3]"))
assert_true(@json.valid("null"))
assert_true(@json.valid("false"))

// Parse JSON string into Json value
let json = @json.parse("{\"key\": 42}") catch {
(_ : @json.ParseError) => panic()
// _ => panic() // redundant, the type checker won't refine further

}

// Pretty print with indentation
inspect(
json.stringify(indent=2),
content={
let output =
#|{
#| "key": 42
#|}
output
},
)
}

#Object Navigation

///|
test "json object navigation" {
let json = @json.parse(
(
#|{"string":"hello","number":42,"array":[1,2,3]}
),
)

// Access string
let string_opt = if json is { "string": String(str), .. } {
Some(str)
} else {
None
}
debug_inspect(
string_opt,
content=(
#|Some("hello")
),
)

// Access number
let number_opt = if json is { "number": Number(num, ..), .. } {
Some(num)
} else {
None
}
debug_inspect(number_opt, content="Some(42)")

// Access array
let array_opt = if json is { "array": Array(arr), .. } {
Some(arr)
} else {
None
}
debug_inspect(array_opt, content="Some([Number(1), Number(2), Number(3)])")

// Handle missing keys gracefully
json is { "value"? : value, .. }
debug_inspect(value, content="None")
}

#Array Navigation

///|
test "json array navigation" {
let array = @json.parse("[1, 2, 3, 4, 5]")

// Access by index
let first = if array is Array([f, ..]) { Some(f) } else { None }
debug_inspect(first, content="Some(Number(1))")

// Access out of bounds
let missing = if array is Array(arr) { arr.get(10) } else { None }
debug_inspect(missing, content="None")

// Iterate through array
array is Array(values)
debug_inspect(
values.iter().to_array(),
content=(
#|[Number(1), Number(2), Number(3), Number(4), Number(5)]
),
)
}

#Type-Safe JSON Conversion

#From JSON to Native Types

///|
test "json decode" {
// Decode basic types
let json_number = (42 : Json)
let number : Int = @json.from_json(json_number)
inspect(number, content="42")

// Decode arrays
let json_array = ([1, 2, 3] : Json)
let array : Array[Int] = @json.from_json(json_array)
debug_inspect(array, content="[1, 2, 3]")

// Decode maps
let json_map = ({ "a": 1, "b": 2 } : Json)
let map : Map[String, Int] = @json.from_json(json_map)
debug_inspect(
map,
content=(
#|{ "a": 1, "b": 2 }
),
)
}

#Error Handling with JSON Path

///|
test "json path" {
// Handle decode errors
try {
let _arr : Array[Int] = @json.from_json(([42, "not a number", 49] : Json))
panic()
} catch {
JsonDecodeError((path, msg)) => {
inspect(path, content="/1")
inspect(msg, content="Int::from_json: expected number")
}
}
}

#Stringify Options

stringify accepts optional parameters: indent for pretty-printing, escape_slash to escape / characters, and replacer to transform or filter keys.

///|
test "stringify options" {
let json : Json = { "name": "Alice", "age": 30, "secret": "hidden" }
// compact (default)
inspect(
json.stringify(),
content=(
#|{"name":"Alice","age":30,"secret":"hidden"}
),
)
// pretty-printed
inspect(
json.stringify(indent=2),
content=(
#|{
#| "name": "Alice",
#| "age": 30,
#| "secret": "hidden"
#|}
),
)
}

#Replacer

Replacer controls which keys appear in stringify output or transforms values during Json::transform.

///|
test "replacer keep and exclude" {
let json : Json = { "name": "Alice", "age": 30, "secret": "hidden" }
// keep only specified keys
let kept = json.stringify(replacer=@json.Replacer::keep(["name", "age"]))
inspect(
kept,
content=(
#|{"name":"Alice","age":30}
),
)
// exclude specified keys
let excluded = json.stringify(replacer=@json.Replacer::exclude(["secret"]))
inspect(
excluded,
content=(
#|{"name":"Alice","age":30}
),
)
}

Custom replacer with a function:

///|
test "replacer custom" {
let json : Json = { "x": 1.0, "y": 2.0 }
// double all number values
let replaced = json.transform(
Replacer(fn(_key, value) {
match value {
Number(n, ..) => Some(Json::number(n * 2))
other => Some(other)
}
}),
)
inspect(replaced.stringify(), content="{\"x\":2,\"y\":4}")
}

#Parse Errors

parse() raises ParseError with specific variants indicating what went wrong:

///|
test "parse errors" {
// InvalidChar
try {
let _ = @json.parse("{invalid")
panic()
} catch {
InvalidChar(pos, _ch) =>
debug_inspect((pos.line, pos.column), content="(1, 1)")
_ => panic()
}
// InvalidEof
try {
let _ = @json.parse("{\"a\":")
panic()
} catch {
InvalidEof => ()
_ => panic()
}
}

#The FromJson Trait

Types that implement FromJson can be decoded from JSON via @json.from_json(). Built-in support includes: Bool, Int, Int64, UInt, UInt64, Float, Double, String, Char, Array[T], Map[String, V], Option[T], Result[Ok, Err], tuples up to 16 elements, Bytes, and Json itself.

///|
test "from_json" {
// decode a tuple
let json : Json = [1, "hello"]
let tuple : (Int, String) = @json.from_json(json)
debug_inspect(tuple, content="(1, \"hello\")")
// decode an optional
let opt : Int? = @json.from_json(null)
debug_inspect(opt, content="None")
// decode a Result
let json3 : Json = { "Ok": 42 }
let result : Result[Int, String] = @json.from_json(json3)
debug_inspect(result, content="Ok(42)")
}

#JSON-based Snapshot Testing

@json.json_inspect() can be used as an alternative to inspect() when a value's ToJson implementation is considered a better debugging representation than its Show implementation. This is particularly true for deeply-nested data structures.

///|
test "json inspection" {
let null = null

// Simple json values
let json_value : Json = { "key": "value", "numbers": [1, 2, 3] }
@json.json_inspect(json_value, content={
"key": "value",
"numbers": [1, 2, 3],
})

// Null and boolean values
let json_special = { "null": null, "bool": true }
@json.json_inspect(json_special, content={ "null": null, "bool": true })
}

#
FromJson

pub(open) trait FromJson {
fn from_json(Json, JsonPath) -> Self raise JsonDecodeError
}

Trait for types that can be converted from Json
impl FromJson for Unit
impl FromJson for Bool
impl FromJson for Char
impl FromJson for Int
impl FromJson for Int64
impl FromJson for UInt
impl FromJson for UInt64
impl FromJson for Float
impl FromJson for Double
impl FromJson for String
impl FromJson for Option[T]
impl FromJson for Result[Ok, Err]
impl FromJson for FixedArray[X]
impl FromJson for Bytes
impl FromJson for Array[X]
impl FromJson for ArrayView[X]
impl FromJson for Json
impl FromJson for Map[String, V]
impl FromJson for Tuple2[A, B]
impl FromJson for Tuple3[A, B, C]
impl FromJson for Tuple4[A, B, C, D]
impl FromJson for Tuple5[A, B, C, D, E]
impl FromJson for Tuple6[A, B, C, D, E, F]
impl FromJson for Tuple7[A, B, C, D, E, F, G]
impl FromJson for Tuple8[T0, T1, T2, T3, T4, T5, T6, T7]
impl FromJson for Tuple9[T0, T1, T2, T3, T4, T5, T6, T7, T8]
impl FromJson for Tuple10[T0, T1, T2, T3, T4, T5, T6, T7, T8, T9]
impl FromJson for Tuple11[T0, T1, T2, T3, T4, T5, T6, T7, T8, T9, T10]
impl FromJson for Tuple12[T0, T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11]
impl FromJson for Tuple13[T0, T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12]
impl FromJson for Tuple14[T0, T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13]
impl FromJson for Tuple15[T0, T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14]
impl FromJson for Tuple16[T0, T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15]

#
JsonDecodeError

pub(all) suberror JsonDecodeError {
JsonDecodeError((JsonPath, String))
} derive(Eq, Show, ToJson,
Debug
)

Error type JsonDecodeError.

#
JsonDecodeError::equal

#
JsonDecodeError::to_string

fn JsonDecodeError::to_string(self : JsonDecodeError) -> String

#
ParseError

pub(all) suberror ParseError {
InvalidChar(Position, Char)
InvalidEof
InvalidNumber(Position, String)
InvalidIdentEscape(Position)
DepthLimitExceeded
} derive(Eq, ToJson,
Debug
)

Error type ParseError.
impl Show for ParseError

#
ParseError::equal

fn ParseError::equal(ParseError, ParseError) -> Bool

#
ParseError::to_string

fn ParseError::to_string(self : ParseError) -> String

#
JsonPath

type JsonPath derive(Eq,
Debug
)

key and index are mutable so that a decoder can reuse one path node for every element of an array or object instead of allocating a node per element; on large arrays that is worth roughly 3.5x. A reused node must not outlive the decode call that created it.

MoonBit only allows assigning to a constructor's mutable field through a binding that pattern matching has narrowed to that variant, so the decoders in from_json.mbt obtain one with a guard! whose test cannot fail.
impl Show for JsonPath
impl ToJson for JsonPath

#
JsonPath::add_index

fn JsonPath::add_index(self : JsonPath, index : Int) -> JsonPath

Appends an array index to the JSON path.

#
JsonPath::add_key

fn JsonPath::add_key(self : JsonPath, key : String) -> JsonPath

Appends an object key to the JSON path.

#
JsonPath::equal

fn JsonPath::equal(JsonPath, JsonPath) -> Bool

#
JsonPath::to_string

fn JsonPath::to_string(self : JsonPath) -> String

#
Position

pub(all) struct Position {
line : Int
column : Int
} derive(Eq, ToJson,
Debug
)

Type Position used by this package APIs.

#
Position::equal

fn Position::equal(Position, Position) -> Bool

#
Replacer

pub struct Replacer {
// private fields
} derive(
Debug
)

A Replacer provides a way to filter and transform JSON object properties during stringification.

Replacers contain a function that takes a property key and value, and returns:
  • Some(value) to include the property in the output (possibly transformed)
  • None to exclude the property from the output

Only applies to object properties, not array elements.

#
Replacer::Replacer

#alias(new, deprecated="Use `Replacer()` instead")
fn Replacer::Replacer(f : (String, Json) -> Json?) -> Replacer

Create a new Replacer with a custom function.

The function receives (key, value) pairs and should return:
  • Some(transformed_value) to include the property
  • None to exclude the property

Example

test {
// Transform numbers and exclude sensitive fields
ignore(
@json.Replacer((key, value) => {
match key {
"password" | "secret" => None // exclude sensitive fields
_ =>
match value {
Number(n, ..) => Some(Json::number(n * 2.0)) // double all numbers
_ => Some(value) // keep other values as-is
}
}
}),
)
}

#
Replacer::exclude

fn Replacer::exclude(array : ArrayView[StringView]) -> Replacer

Create a Replacer that excludes the specified property keys. All other properties will be included in the output.

Example

test {
let replacer = @json.Replacer::exclude(["password", "secret", "private"])
let json : Json = {
"name": "Alice",
"age": 30.0,
"password": "secret",
"email": "alice@example.com",
}
ignore(json.stringify(replacer~)) // {"name":"Alice","age":30,"email":"alice@example.com"}
}

#
Replacer::keep

fn Replacer::keep(array : ArrayView[StringView]) -> Replacer

Create a Replacer that only keeps the specified property keys. All other properties will be excluded from the output.

Example

test {
let replacer = @json.Replacer::keep(["name", "age", "email"])
let json : Json = {
"name": "Alice",
"age": 30.0,
"email": "alice@example.com",
"password": "secret",
}
ignore(json.stringify(replacer~)) // {"name":"Alice","age":30,"email":"alice@example.com"}
}

Json

#
Json::as_array

#deprecated("Suggestion: `if json is Array(array) { Some(array) } else { None }`")
fn Json::as_array(self : Json) -> Array[Json]?

Try to get this element as an Array

#
Json::as_bool

#deprecated("Suggestion: `if json is True { Some(true) } else if json is False { Some(false) } else { None }`")
fn Json::as_bool(self : Json) -> Bool?

Try to get this element as a Boolean

#
Json::as_null

#deprecated("Suggestion: `if json is Null { Some(()) } else { None }`")
fn Json::as_null(self : Json) -> Unit?

Try to get this element as a Null

#
Json::as_number

#deprecated("Suggestion: `if json is Number(n) { Some(n) } else { None }`")
fn Json::as_number(self : Json) -> Double?

Try to get this element as a Number

#
Json::as_object

#deprecated("This function is deprecated.")
fn Json::as_object(self : Json) -> Map[String, Json]?

Try to get this element as an Object

#
Json::as_string

#deprecated("Suggestion: `if json is String(s) { Some(s) } else { None }`")
fn Json::as_string(self : Json) -> String?

Try to get this element as a String

#
Json::item

#deprecated("Suggestion: `if json is Array(array) { array.get(index) } else { None }`")
fn Json::item(self : Json, index : Int) -> Json?

Try to get this element as a Json Array and get the element at the index as a Json Value

#
Json::stringify

fn Json::stringify(self : Json, escape_slash? : Bool, indent? : Int, replacer? : Replacer) -> String

Convert this Json value to a String
  • escape_slash: Whether to escape '/' as '/' (default: false)
  • indent: Number of spaces to indent nested structures (default: 0 = non-indented)
  • replacer: An optional Replacer function to transform or filter values during stringification

Replacer

The replacer parameter allows you to control which object properties are included in the output and optionally transform values during stringification. Only applies to object properties, not array elements.

Creating Replacers

  1. Replacer(f) - Create a custom replacer with a function (String, Json) -> Json?:
    • Return Some(value) to include the property (possibly transformed)
    • Return None to exclude the property

  2. Replacer::keep(keys) - Include only the specified property keys

  3. Replacer::exclude(keys) - Exclude the specified property keys

Examples

test {
let json : Json = { "a": 1.0, "b": 2.0, "c": 3.0, "password": "secret" }

// Keep only specific keys
let keep_replacer = @json.Replacer::keep(["a", "c"])
ignore(json.stringify(replacer=keep_replacer)) // {"a":1,"c":3}

// Exclude sensitive keys
let exclude_replacer = @json.Replacer::exclude(["password"])
ignore(json.stringify(replacer=exclude_replacer)) // {"a":1,"b":2,"c":3}

// Custom transformation
let transform_replacer = @json.Replacer((_key, value) => {
match value {
Number(n, ..) => Some(Json::number(n * 10.0)) // multiply numbers by 10
_ => Some(value) // keep other values unchanged
}
})
ignore(json.stringify(replacer=transform_replacer)) // {"a":10,"b":20,"c":30,"password":"secret"}

// Filter and transform
let filter_replacer = @json.Replacer((key, value) => {
match key {
"password" => None // exclude password
_ =>
match value {
Number(n, ..) => Some(Json::number(n + 100.0)) // add 100 to numbers
_ => Some(value)
}
}
})
ignore(json.stringify(replacer=filter_replacer)) // {"a":101,"b":102,"c":103}
}

Nested Objects

Replacers work recursively on nested objects:

test {
let nested : Json = {
"user": { "name": "Alice", "password": "secret" },
"id": 123.0,
}
let safe_replacer = @json.Replacer::exclude(["password"])
ignore(nested.stringify(replacer=safe_replacer)) // {"user":{"name":"Alice"},"id":123}
}

#
Json::transform

fn Json::transform(self : Json, replacer : Replacer) -> Json

Transform a JSON value by applying a replacer recursively to all object properties.

Unlike stringify(replacer~) which only affects the string output, transform() returns a new JSON value with properties filtered and transformed according to the replacer.

This is useful when you want to create a modified JSON structure that can be further processed, rather than just converting to a string.

Example

test {
let json : Json = {
"user": { "name": "Alice", "password": "secret", "age": 30.0 },
"id": 123.0,
}

// Remove sensitive data and double numeric values
ignore(
json.transform(
Replacer((key, value) => {
match key {
"password" => None // exclude password fields
_ =>
match value {
Number(n, ..) => Some(Json::number(n * 2.0)) // double numbers
_ => Some(value) // keep other values
}
}
}),
),
)
// Result: { "user": { "name": "Alice", "age": 60 }, "id": 246 }
}

Behavior

  • Recursively applies the replacer to all nested objects
  • Non-object values (arrays, strings, numbers, etc.) are returned unchanged
  • The original JSON value is not modified; a new value is returned

#
Json::value

#deprecated("Suggestion: `if json is Object(obj) { obj.get(key) } else { None }`")
fn Json::value(self : Json, key : String) -> Json?

Try to get this element as a Json Object and get the element with the key as a Json Value
impl Show for Json
impl ToJson for Json

#
from_json

fn[T : FromJson] from_json(json : Json, path? : JsonPath) -> T raise JsonDecodeError

Create from json.

#
json_inspect

#alias(inspect, deprecated="Use `json_inspect` without package name instead.")
#callsite(autofill(args_loc, loc))
fn json_inspect(obj : &ToJson, content? : Json, loc~ : SourceLoc, args_loc~ : ArgsLoc) -> Unit raise InspectError

Inspect JSON value with snapshot-friendly formatting.

#
parse

fn parse(input : StringView, max_nesting_depth? : Int) -> Json raise ParseError

Parse a JSON input string into a Json value, with an optional maximum nesting depth (default is 1024)

#
to_json

fn[T : ToJson] to_json(value : T) -> Json

Convert value to Json.

This is the free-function form of the ToJson trait method, symmetric with @json.from_json. Prefer it over the promoted value.to_json() method.

test {
@debug.debug_inspect(
@json.to_json([1, 2, 3]),
content="Array([Number(1), Number(2), Number(3)])",
)
}

#
valid

fn valid(input : StringView) -> Bool

Validate input and return whether it is valid.