Skip to content

sparkles:wired — JSON serialization

sparkles:wired maps D values to and from JSON by structural introspection — the mapping is derived from each type at compile time, with no schemas or code generation, and optional @Wire* attributes to tune wire names, casing, and representation.

Both directions are Expected-based and never throw: toJSON returns an Expected!(JsonString, JsonError) and fromJSON!T returns an Expected!(T, JsonError), so a failure is a value you branch on rather than an exception you catch. The library builds on std.json for parsing and printing.

Installation

sh
dub add sparkles:wired
d
#!/usr/bin/env dub
/+ dub.sdl:
    dependency "sparkles:wired" version "*"
+/
sdl
dependency "sparkles:wired" version "*"
json
{
    "dependencies": {
        "sparkles:wired": "*"
    }
}

Decode JSON — fromJSON

fromJSON!T parses JSON text with wired's native engine and reconstructs a T, recursing through arrays, objects, and nested aggregates. It returns an Expected!(T, JsonError) whose .value holds the decoded result on success:

d
#!/usr/bin/env dub
/+ dub.sdl:
    name "wired_from_json"
    dependency "sparkles:wired" version="*"
+/

import std.stdio : writeln;
import sparkles.wired : fromJSON;

struct Server
{
    string host;
    ushort port;
    string[] tags;
}

void main()
{
    Server server = fromJSON!Server(
        `{ "host": "localhost", "port": 8080, "tags": ["web", "edge"] }`).value;
    writeln(server);
}
Server("localhost", 8080, ["web", "edge"])

Encode values — toJSON

toJSON is the inverse: it walks a value and produces minified JSON text as an Expected!(JsonString, JsonError) (struct fields in declaration order, are emitted in sorted order):

d
#!/usr/bin/env dub
/+ dub.sdl:
    name "wired_to_json"
    dependency "sparkles:wired" version="*"
+/
import std.stdio : writeln;
import sparkles.wired : toJSON;

struct Server
{
    string host;
    ushort port;
    string[] tags;
}

void main()
{
    auto server = Server("localhost", 8080, ["web", "edge"]);
    writeln(server.toJSON.value[]);
}
{"host":"localhost","port":8080,"tags":["web","edge"]}

Deterministic, diff-friendly output

Generated JSON that lives in version control wants a layout that never moves on its own. JsonWriteOptions carries that layout — indent unit, line terminator, and key order — and writeJSON / writeJSONFile take it (plus an optional key comparator) as compile-time arguments:

d
#!/usr/bin/env dub
/+ dub.sdl:
    name "wired_layout"
    dependency "sparkles:wired" version="*"
+/
import std.array : appender;
import std.stdio : writeln;

import sparkles.wired : writeJSON;
import sparkles.wired.json.writer : JsonWriteOptions, KeyOrder;

/// Sorts numeric-looking keys numerically, and after any other key.
bool versionish(scope const(char)[] a, scope const(char)[] b)
    @safe pure nothrow @nogc
{
    static bool numeric(scope const(char)[] s)
    {
        foreach (c; s)
            if (c < '0' || c > '9')
                return false;
        return s.length > 0;
    }

    if (numeric(a) != numeric(b))
        return !numeric(a);
    if (numeric(a) && a.length != b.length)
        return a.length < b.length; // 9 before 10
    return a < b;
}

void main()
{
    auto releases = ["10": "newest", "9": "older", "name": "widget"];

    // Four-space indent, keys through the custom comparator.
    enum layout = JsonWriteOptions(
        pretty: true,
        indent: "    ",
        keyOrder: KeyOrder.sorted,
    );

    auto buf = appender!string;
    writeJSON!(layout, versionish)(releases, buf);
    writeln(buf[]);
}
Output
{
    "name": "widget",
    "9": "older",
    "10": "newest"
}

One rule governs key order. Associative arrays and JSONValue objects have no inherent order, so they are always sorted — output never depends on hash order. Struct fields and parsed-document members do have one, so they follow keyOrder: declared (the default) keeps declaration/source order, sorted sorts by wire key. The comparator applies at every sorted position and must be CTFE-callable, since struct field order is resolved at compile time.

writeJSONFile!(layout)(value, path) writes the same text atomically, with a trailing newline. Pin layout at the call site for any file you check in: a future change to wired's defaults then cannot silently reformat it.

Errors as values

Because decoding never throws, malformed input surfaces as the JsonError payload of the returned Expected!(T, JsonError) — branch on hasValue / hasError and inspect the failure as data. Decode errors carry a precise message, including, for enums, the set of names that would have matched:

d
#!/usr/bin/env dub
/+ dub.sdl:
    name "wired_errors"
    dependency "sparkles:wired" version="*"
+/

import std.stdio : writeln;
import sparkles.wired : fromJSON;

enum Mode { off, on, automatic }

void main()
{
    // fromJSON never throws — it returns Expected!(T, JsonError).
    auto good = fromJSON!Mode(`"on"`);
    writeln("value: ", good.hasValue, " ", good.value);

    auto bad = fromJSON!Mode(`"sideways"`);
    writeln("error: ", bad.hasError);
    writeln("       ", bad.error);
}
value: true on
error: true
       Cannot decode Mode at $ from JSON string "sideways": expected one of: off, on, automatic

API

SymbolDescription
writeJSON(value, ref writer)Expected!(void, JsonError)Stream JSON into any output range — the primary encode form (never throws).
toJSON(value)Expected!(JsonString, JsonError)Encode a value to minified text; a failure is captured as the JsonError payload (never throws).
fromJSON!T(text)Expected!(T, JsonError)Parse and decode JSON text; a failure is captured as the JsonError payload (never throws).
readJSONFile!T(string path)Expected!(T, JsonError)Read, parse, and decode a file; the error identifies the failing stage (read, parse, decode).
writeJSONFile!(opts, keyLess)(value, path)Expected!(void, JsonError)Encode and write to path atomically, creating parent directories; opts pins indent and key order.
@WireName("…")Field / enum-member UDA overriding the JSON wire name.
@WireCase(CaseStyle.…)Recase field / member names (e.g. snakeCase, kebabCase).
@WireRepr(Repr.…)Serialize an enum by member name (default) or underlying value.

Supported types

The same structural mapping covers a broad range of types, in both directions:

  • Scalarsbool, string, char, integral and floating-point types
  • Enums — by member name, or a @WireName / @WireCase / @WireRepr override
  • Arrays / slices — of any supported element type
  • Associative arrays — keyed by string or by an enum
  • Aggregates (struct) — field by field, under their member names
  • SumType — encoded as its active variant; decoding tries each variant in turn
  • Nullable!T / Optional!T — JSON null ⇄ the empty value
  • Ternary — JSON null / true / false
  • SysTime — an ISO-8601 extended string
  • JSONValue — passed through unchanged

Every entry below is encoded with toJSON and decoded back with fromJSON, and the two agree — the mapping round-trips:

d
#!/usr/bin/env dub
/+ dub.sdl:
    name "wired_showcase"
    dependency "sparkles:wired" version="*"
+/
import std.stdio : writefln;
import std.sumtype : SumType;
import std.typecons : Nullable, Ternary;
import sparkles.wired : fromJSON, toJSON;

enum Suit
{
    spades,
    hearts,
}

struct Card
{
    Suit suit;
    int rank;
}

alias Cell = SumType!(int, string);

void show(T)(string label, T value)
{
    auto json = value.toJSON.value;         // encode → minified text
    auto back = fromJSON!T(json[]).value;    // decode again → T
    writefln("%-12s %-28s round-trips=%s", label, json[], back == value);
}

void main()
{
    show("int",         42);
    show("double",      3.5);
    show("bool",        true);
    show("string",      "hi");
    show("enum",        Suit.hearts);
    show("enum[]",      [Suit.spades, Suit.hearts]);
    show("int[string]", ["a": 1, "b": 2]);
    show("int[Suit]",   [Suit.spades: 1, Suit.hearts: 2]);
    show("struct",      Card(Suit.hearts, 10));
    show("SumType",     Cell("text"));
    show("Nullable",    Nullable!int(7));
    show("Ternary",     Ternary.unknown);
}
int          42                           round-trips=true
double       3.5                          round-trips=true
bool         true                         round-trips=true
string       "hi"                         round-trips=true
enum         "hearts"                     round-trips=true
enum[]       ["spades","hearts"]          round-trips=true
int[string]  {"a":1,"b":2}                round-trips=true
int[Suit]    {"hearts":2,"spades":1}      round-trips=true
struct       {"suit":"hearts","rank":10}  round-trips=true
SumType      "text"                       round-trips=true
Nullable     7                            round-trips=true
Ternary      null                         round-trips=true

Enum wire names — @WireName

By default an enum member maps to its source name. Annotate it with @WireName to decouple the JSON spelling from the D identifier — useful for kebab-case or otherwise non-identifier wire names. (For a whole-enum recasing rule, reach for @WireCase instead.) Both directions honour the override:

d
#!/usr/bin/env dub
/+ dub.sdl:
    name "wired_enum_names"
    dependency "sparkles:wired" version="*"
+/
import std.json : parseJSON;
import std.stdio : writeln;
import sparkles.wired : fromJSON, toJSON, WireName;

enum Level
{
    @WireName("low") low,
    @WireName("high-priority") high,
}

void main()
{
    writeln(Level.high.toJSON.value[]);                                // custom wire name
    writeln(parseJSON(`"high-priority"`).fromJSON!Level.value == Level.high);
}
"high-priority"
true