nim-serde JSON

Explore JSON serialization and deserialization using nim-serde, an improved alternative to std/json.

Table of Contents

Serialization API

The nim-serde JSON serialization API provides several ways to convert Nim values to JSON.

Basic Serialization with % operator

The % operator converts Nim values to JsonNode objects, which can then be converted to JSON strings:

import pkg/serde/json

# Basic types
assert %42 == newJInt(42)
assert %"hello" == newJString("hello")
assert %true == newJBool(true)

# Arrays and sequences
let arr = newJArray()
arr.add(newJInt(1))
arr.add(newJInt(2))
arr.add(newJInt(3))
assert $(%[1, 2, 3]) == $arr

Object Serialization

Objects can be serialized using the % operator, which automatically handles field serialization based on the object's configuration:

import pkg/serde/json

type Person = object
  name {.serialize.}: string
  age {.serialize.}: int
  address: string  # Not serialized by default in OptIn mode

let person = Person(name: "John", age: 30, address: "123 Main St")
let jsonNode = %person
assert jsonNode.kind == JObject
assert jsonNode.len == 2
assert jsonNode["name"].getStr == "John"
assert jsonNode["age"].getInt == 30
assert "address" notin jsonNode

Serialization with %*

The %* macro provides a more convenient way to create JSON objects:

import pkg/serde/json

let jsonObj = %*{
  "name": "John",
  "age": 30,
  "hobbies": ["reading", "coding"],
  "address": {
    "street": "123 Main St",
    "city": "Anytown"
  }
}

assert jsonObj.kind == JObject
assert jsonObj["name"].getStr == "John"
assert jsonObj["age"].getInt == 30
assert jsonObj["hobbies"].kind == JArray
assert jsonObj["hobbies"][0].getStr == "reading"
assert jsonObj["address"]["street"].getStr == "123 Main St"

Converting to JSON String with toJson

The toJson function converts any serializable value directly to a JSON string:

import pkg/serde/json

type Person = object
  name {.serialize.}: string
  age {.serialize.}: int

let person = Person(name: "John", age: 30)
assert person.toJson == """{"name":"John","age":30}"""

Serialization Modes

nim-serde offers three modes to control which fields are serialized:

import pkg/serde/json

# OptIn mode (default): Only fields with {.serialize.} are included
type Person1 = object
  name {.serialize.}: string
  age {.serialize.}: int
  address: string  # Not serialized

assert Person1(name: "John", age: 30, address: "123 Main St").toJson == """{"name":"John","age":30}"""

# OptOut mode: All fields are included except those marked to ignore
type Person2 {.serialize.} = object
  name: string
  age: int
  ssn {.serialize(ignore=true).}: string  # Not serialized

assert Person2(name: "John", age: 30, ssn: "123-45-6789").toJson == """{"name":"John","age":30}"""

# Strict mode: All fields are included, and an error is raised if any fields are missing
type Person3 {.serialize(mode=Strict).} = object
  name: string
  age: int

assert Person3(name: "John", age: 30).toJson == """{"name":"John","age":30}"""

Field Customization for Serialization

Fields can be customized with various options:

import pkg/serde/json

# Field customization for serialization
type Person {.serialize(mode = OptOut).} = object
  firstName {.serialize(key = "first_name").}: string
  lastName {.serialize(key = "last_name").}: string
  age: int  # Will be included because we're using OptOut mode
  ssn {.serialize(ignore = true).}: string  # Sensitive data not serialized

let person = Person(
  firstName: "John",
  lastName: "Doe",
  age: 30,
  ssn: "123-45-6789"
)

let jsonNode = %person
assert jsonNode.kind == JObject
assert jsonNode["first_name"].getStr == "John"
assert jsonNode["last_name"].getStr == "Doe"
assert jsonNode["age"].getInt == 30
assert "ssn" notin jsonNode

# Convert to JSON string
let jsonStr = toJson(person)
assert jsonStr == """{"first_name":"John","last_name":"Doe","age":30}"""

Deserialization API

nim-serde provides a type-safe way to convert JSON data back into Nim types.

Basic Deserialization with fromJson

The fromJson function converts JSON strings or JsonNode objects to Nim types:

import pkg/serde/json
import pkg/questionable/results

type Person = object
  name: string
  age: int

let jsonStr = """{"name":"John","age":30}"""
let result = Person.fromJson(jsonStr)

# Using the ! operator from questionable to extract the value
assert !result == Person(name: "John", age: 30)

Error Handling

Deserialization returns a Result type from the questionable library, allowing for safe error handling:

import pkg/serde/json
import pkg/questionable/results

type Person = object
  name: string
  age: int

let invalidJson = """{"name":"John","age":"thirty"}"""
let errorResult = Person.fromJson(invalidJson)
assert errorResult.isFailure
assert errorResult.error of UnexpectedKindError

Parsing JSON with JsonNode.parse

For parsing raw JSON without immediate deserialization:

import pkg/serde/json
import pkg/questionable/results

let jsonStr = """{"name":"John","age":30,"hobbies":["reading","coding"]}"""
let parseResult = JsonNode.parse(jsonStr)
assert parseResult.isSuccess
let jsonNode = !parseResult
assert jsonNode["name"].getStr == "John"
assert jsonNode["age"].getInt == 30
assert jsonNode["hobbies"].kind == JArray
assert jsonNode["hobbies"][0].getStr == "reading"
assert jsonNode["hobbies"][1].getStr == "coding"

Deserialization Modes

nim-serde offers three modes to control how JSON is deserialized:

import pkg/serde/json
import pkg/questionable/results

# OptOut mode (default for deserialization)
type PersonOptOut = object
  name: string
  age: int

let jsonOptOut = """{"name":"John","age":30,"address":"123 Main St"}"""
let resultOptOut = PersonOptOut.fromJson(jsonOptOut)
assert resultOptOut.isSuccess
assert !resultOptOut == PersonOptOut(name: "John", age: 30)

# OptIn mode
type PersonOptIn {.deserialize(mode = OptIn).} = object
  name {.deserialize.}: string
  age {.deserialize.}: int
  address: string  # Not deserialized by default in OptIn mode

let jsonOptIn = """{"name":"John","age":30,"address":"123 Main St"}"""
let resultOptIn = PersonOptIn.fromJson(jsonOptIn)
assert resultOptIn.isSuccess
assert (!resultOptIn).name == "John"
assert (!resultOptIn).age == 30
assert (!resultOptIn).address == ""  # address is not deserialized

# Strict mode
type PersonStrict {.deserialize(mode = Strict).} = object
  name: string
  age: int

let jsonStrict = """{"name":"John","age":30}"""
let resultStrict = PersonStrict.fromJson(jsonStrict)
assert resultStrict.isSuccess
assert !resultStrict == PersonStrict(name: "John", age: 30)

# Strict mode with extra field (should fail)
let jsonStrictExtra = """{"name":"John","age":30,"address":"123 Main St"}"""
let resultStrictExtra = PersonStrict.fromJson(jsonStrictExtra)
assert resultStrictExtra.isFailure

Field Customization for Deserialization

Fields can be customized with various options for deserialization:

import pkg/serde/json
import pkg/questionable/results

type User = object
  firstName {.deserialize(key = "first_name").}: string
  lastName {.deserialize(key = "last_name").}: string
  age: int
  internalId {.deserialize(ignore = true).}: int

let userJsonStr = """{"first_name":"Jane","last_name":"Smith","age":25,"role":"admin"}"""
let result = User.fromJson(userJsonStr)
assert result.isSuccess
assert (!result).firstName == "Jane"
assert (!result).lastName == "Smith"
assert (!result).age == 25
assert (!result).internalId == 0  # Default value, not deserialized

Using as a Drop-in Replacement for std/json

nim-serde can be used as a drop-in replacement for the standard library's json module with improved exception handling.

# Instead of:
# import std/json
import pkg/serde/json
import pkg/questionable/results

# Using nim-serde's JSON API which is compatible with std/json
let jsonNode = %* {
  "name": "John",
  "age": 30,
  "isActive": true,
  "hobbies": ["reading", "swimming"]
}

# Accessing JSON fields using the same API as std/json
assert jsonNode.kind == JObject
assert jsonNode["name"].getStr == "John"
assert jsonNode["age"].getInt == 30
assert jsonNode["isActive"].getBool == true
assert jsonNode["hobbies"].kind == JArray
assert jsonNode["hobbies"][0].getStr == "reading"
assert jsonNode["hobbies"][1].getStr == "swimming"

# Converting JSON to string
let jsonStr = $jsonNode

# Parsing JSON from string with better error handling
let parsedResult = JsonNode.parse(jsonStr)
assert parsedResult.isSuccess
let parsedNode = !parsedResult
assert parsedNode.kind == JObject
assert parsedNode["name"].getStr == "John"

# Pretty printing
let prettyJson = pretty(jsonNode)

Custom Type Serialization

You can extend nim-serde to support custom types by defining your own % operator overloads and fromJson procs:

import pkg/serde/json
import pkg/serde/utils/errors
import pkg/questionable/results
import std/strutils

# Define a custom type
type
  UserId = distinct int

# Custom serialization for UserId
proc `%`*(id: UserId): JsonNode =
  %("user-" & $int(id))

# Custom deserialization for UserId
proc fromJson*(_: type UserId, json: JsonNode): ?!UserId =
  if json.kind != JString:
    return failure(newSerdeError("Expected string for UserId, got " & $json.kind))
  
  let str = json.getStr()
  if str.startsWith("user-"):
    let idStr = str[5..^1]
    try:
      let id = parseInt(idStr)
      success(UserId(id))
    except ValueError:
      failure(newSerdeError("Invalid UserId format: " & str))
  else:
    failure(newSerdeError("UserId must start with 'user-' prefix"))

# Test serialization
let userId = UserId(42)
let jsonNode = %userId
assert jsonNode.kind == JString
assert jsonNode.getStr() == "user-42"

# Test deserialization
let jsonStr = "\"user-42\""
let parsedJson = !JsonNode.parse(jsonStr)
let result = UserId.fromJson(parsedJson)
assert result.isSuccess
assert int(!result) == 42

# Test in object context
type User {.serialize(mode = OptOut).} = object
  id: UserId
  name: string

let user = User(id: UserId(123), name: "John")
let userJson = %user
assert userJson.kind == JObject
assert userJson["id"].getStr() == "user-123"
assert userJson["name"].getStr() == "John"

# Test deserialization of object with custom type
let userJsonStr = """{"id":"user-123","name":"John"}"""
let userResult = User.fromJson(userJsonStr)
assert userResult.isSuccess
assert int((!userResult).id) == 123
assert (!userResult).name == "John"

Implementation Details

The JSON serialization in nim-serde is based on the % operator pattern:

  1. The % operator converts values to JsonNode objects
  2. Various overloads handle different types (primitives, objects, collections)
  3. The toJson function converts the JsonNode to a string

This approach makes it easy to extend with custom types by defining your own % operator overloads.

For deserialization, the library uses compile-time reflection to map JSON fields to object fields, respecting the configuration provided by pragmas.