Skip to contents

Writing an R value produces JSON that a human can read, and reading it back produces the same value. Ordinary values are emitted as ordinary JSON; only what JSON cannot express is annotated, which keeps the document diffable, greppable and editable by hand.

Usage

json_read(path)

json_read_str(txt)

json_write(x, path, pretty = TRUE, typed = TRUE, self_contained = FALSE)

json_write_str(x, pretty = FALSE, typed = TRUE, self_contained = FALSE)

Arguments

path

Path to write to or read from.

txt

Document to read, as a length-one character vector.

x

Value to write.

pretty

Whether to indent the output. Files default to indented, because a persistence format is read in diffs; strings default to compact.

typed

Whether to record what JSON cannot express. The default writes the annotated form this package reads back unchanged; FALSE writes plain JSON for a consumer that brings its own schema.

self_contained

Whether to carry a class definition the document would otherwise record by name. The default records the name wherever one finds the class again, which is what a wire format wants; TRUE carries the definition instead, which is what an archive wants. An S7 class and an R6 class generator are reached, with vignette("design") setting out which names are left alone and why.

Value

The json_write() function returns path invisibly and json_write_str() a length-one character vector. Both readers return the value the document describes.

Details

Two properties hold, and are what the test suite checks:

The first holds for every supported value: every atomic type, missing values of each type, the non-finite doubles, attributes of any shape, language objects, closures, and objects built with S3, S4 or S7. Four values need it stated differently. An environment recorded by its contents comes back a new environment, which is the exception base R's own serialize() makes as well: what comes back binds the same names to the same values, locked the same way, under a parent that is itself equivalent, and a closure over one is equivalent for that same reason. An S7 class a document carries the definition of is equivalent for that same reason wherever a part of it closes over such an environment, which S7 builds for the constructor of every class that has a parent. An R6 class a document carries the definition of is such an environment itself, and holds its parent where the class it was written from held the expression finding one. A string R has not declared an encoding for comes back declared UTF-8, so the property holds on its bytes rather than under identical(). The second holds for every document this package can write. Foreign documents are read under the same grammar and normalize on the first round trip, since a mixed-type array such as [1, "a"] has to come back as a list. Two things are refused instead of normalized, both inside the namespace the ~ prefix reserves. A key beginning with a single ~ is a format tag, and one this reader does not know is an error rather than a name. A string beginning with ~ and a reserved discriminator, which is z for what JSON has no lexeme for and : for a symbol, is a tag as well, and an unknown one is an error rather than text; every other tilde-leading string stays a string, so ~/data is a path.

Two rules decide the shape of a document. A JSON array of scalars is an atomic vector and a JSON object is a named list, so the two containers mean what they mean everywhere else. A length-one vector is written bare wherever an array could not be mistaken for it, which is at the document root, as an object value, as an attribute and as the payload of a tagged object; only as an array element does it keep its brackets, because there the brackets are the sole thing separating list(1, 2) from c(1, 2).

Some JSON is written for a consumer that already defines the shape it expects. There R's types are noise, since the document has to satisfy an external schema rather than describe the value it came from, and the typed flag says so. Plain mode is this format with the annotations left out: the two container rules and the number lexemes stay, the S4 bit goes, and a length-one vector renders as a scalar unless it is AsIs, in which case it keeps its brackets. That last rule is not a setting, because shape requirements run both ways inside one document — a schema wants a scalar at additionalProperties and an array at required whatever its length — and no encoder can tell the two apart by looking, since at length one a scalar and a one-element array are the same R object. The distinction already lives in the value, where I("x") differs from "x", so plain mode renders it rather than importing a policy for it.

Attributes are not quite dropped wholesale, and the rule that decides which two survive is worth stating, because it predicts the rest. JSON puts two questions to every value that the container rules leave open — object or array, and at length one scalar or array — and plain mode reads the attributes that answer them: the names of a list, and the AsIs marker. Nothing else is asked anything. A dim answers neither, since a vector is a flat array with one or without one, so a matrix flattens; the names of an atomic vector answer neither either, since an atomic vector is an array whatever its elements are called; and levels, tzone, units and a class naming a type carry meaning rather than shape, so a factor writes its codes and a Date its number.

Two consequences are worth naming, because in both plain mode writes what the default refuses. Nothing walks into an attribute, so a handle that is only reachable through one is dropped along with it: a connection is an integer wearing an external pointer, and where the default stops at that pointer, plain mode never reaches it and writes the bare slot index. And a json_state() method is not consulted, since a method says how to persist a value and plain mode is not persistence — so a method written to keep a field out of a document does not stand between typed = FALSE and that field. Both follow from the rule above rather than qualifying it, and vignette("handles") argues the default's side of each.

Nothing is written in plain mode that the value is not. A missing value becomes null, which is what JSON spells absence with, and everything the annotations were the only way to write is refused where it sits, naming the path: complex and raw values, the non-finite doubles, symbols, calls, closures and environments. What plain mode does not do is escape, since the consumer asked for the name and the string it asked for, so a value carrying a leading ~ of its own reaches the document bare and is read back the way any foreign document carrying one is: a key spelling a tag this reader does not know is refused, and a string spelling one it does know comes back as that tag. Reading a plain document returns what the document says rather than the value that wrote it, which is what the default mode is for.

Text is carried as UTF-8. A string R has declared as UTF-8 or latin1 is converted from what it declares, and one it has not declared is taken as the bytes it holds rather than translated through the locale, so the same value writes the same document on every machine. Undeclared bytes that are not valid UTF-8 have no reading to fall back on and are refused, naming the path they sit at, as is a string declared with the "bytes" encoding. A document carries one encoding, so every string read out of one comes back marked UTF-8, and an undeclared string therefore revives declared. Comparing the two with identical() translates the native one through the locale and so disagrees wherever that locale cannot represent the bytes; the bytes themselves are the same in every locale, and only the declaration has moved.

An environment is recorded by name wherever a name finds it again: the global, base and empty environments, a namespace, a package environment and the imports environment of a namespace. Those come back as the object they were written from. Anything else is recorded by its contents, with the parent following the same rule and the locked bit and locked bindings recorded alongside, and comes back equivalent. A recorded name that is not available on the way back is replaced by the global environment with a warning, the way base R already does.

Reference identity is recorded across a document. A reference the walk reaches more than once is numbered with a ~id where it is first written, and each later position carries a ~ref naming that number rather than a second copy, so positions holding one environment on the way in hold one environment on the way back. Nothing is numbered where nothing repeats, which leaves a document carrying no sharing as it was. A cycle rides the same numbering, since an environment is built and numbered before what it binds is read: an environment whose parent frame binds it back comes back bound that way. What stays refused, naming both ends of it, is a cycle closing through an object the extension protocol builds in one call, an opted-in R6 instance among them, since a constructor cannot be handed an object that already exists. So is one through an R6 class a document carries the definition of, which is rebuilt in one call the same way.

A language object is a value rather than a handle, so it round-trips exactly and nothing about it is deparsed. A call, an expression and a pairlist are written as their elements under a ~t naming the type, keeping the argument names R stores as tags, and a symbol is written as the string ~:name. Attributes ride the ordinary rule, which is what carries the .Environment of a formula, so y ~ x round-trips once an environment does.

A closure compares by its parts rather than by reference, so it is a value as well and round-trips as far as its environment does. Formals, body and environment are written under a ~t of closure, and attributes ride the ordinary rule. A primitive closes over nothing and is recorded by the name that finds it again, which is what base R does with one. No source reference is recorded: the srcref a parser attaches to a function definition, to a { block and to what parse() returns is dropped, which keeps a document diffable and leaves the value equal under identical(), whose default ignores one. A byte-compiled closure is written from body(), which is the source tree it was compiled from.

Values that are handles rather than data stay out: an external pointer is refused rather than silently written as something else, and an environment binding holding one is refused with it. So is a binding holding a promise, since forcing it on the writer's own initiative could run arbitrary code, and an active binding, since reading it would do the same and record the result as though it were a plain value. That reaches a closure through the frame it closes over, where an argument the function was called with stays a promise whether or not it has been forced. A class that owns such a handle can still be persisted by writing a json_state() method for it.

Slots and properties are attributes, so an S4 or S7 object is rebuilt by the attribute rule rather than by whatever the class constructs one with. The check the class does supply is run on the way back — methods::validObject() for S4 and S7::validate() for S7 — so a document edited into a value the class rejects is refused where it is read, and an S7 property the document leaves out or spells as the wrong type is caught with it. What the check does not stand in for is the construction it reached past: an initialize method for S4 and a custom constructor for S7 do not run, so a slot or property one of them would have derived comes back as the document spells it. A class this session does not hold leaves nothing to check against, and a document naming one reads as it always has.

An S7 class is recorded by the name that finds it again wherever one does, and by its definition wherever none does. S7 sets package for a class defined in a package and leaves it NULL for every class defined outside one, which is the same question, so that attribute is what decides: a package-scoped class is recorded as the class and package it names, and a class with no package carries its parent, properties, constructor and validator instead, so a document written from it reads in a session where no such class exists. Every class those parts name is recorded by what identifies it rather than walked into — one S7 itself binds by the name it holds there, an S3 class by its class vector, a union by its members, and a class of your own by the two forms above — since every refusal a walk into one hits is inside S7's own machinery rather than in the class you wrote. The class vector an object records is checked against the class it resolves to, so a document where the two disagree is refused rather than dispatching on the one and taking its properties from the other.

Which of the two a class gets is a default rather than a rule, and it is the self_contained flag that overrides it. A document written with that flag set carries the definition of every S7 class in it, package-scoped or not, because a reference resolves against the reader's session rather than the writer's: the package may not be installed where the document is read, may have been renamed since, or may have drifted in a way that still validates. That is the answer an archive wants where a wire format wants the reference. The definition carries the package the class was scoped by, so the qualified class vector an instance records still names the class the document rebuilds. That vector is also what S7 dispatches on, so methods still come from the session reading the document, and a method written for a newer version of the class can meet an object in the shape the document archived.

What the flag does not reach is the rest of what this format records by name, and the reason differs across that set rather than falling out of one rule. A primitive and a class the S7 package itself binds have no definition to carry, a walk into either landing in machinery rather than in anything an author wrote, so there the name is the only representation available. A namespace, a package environment and the imports environment of one could be carried and should not be, since what that records is a frozen copy of an installed package where a reader wants the package that is installed. The global environment is the same answer for the opposite reason, being where a reference is most fragile and where recording by contents is least plausible alike: one closure over it would put a whole workspace into the document. The class name an S4 object records keeps its reference as well, since an S4 definition is an entry in a registry the whole session shares rather than a value, and carrying one would mean registering it where the document is read. So does a record written by a json_state() method, whose class vector finds the json_revive() method rebuilding it: that method is the class author's code rather than part of the value, so it stays where the author registered it. An R6 instance opted in through r6_state() is such a record, and finds its generator by name as well. Plain mode records no class at all, so it and the flag cannot both be asked for, and the second contract holds within a mode rather than across the pair, a document carrying a definition writing back to itself where it is written the same way.

An R6 instance is refused as well, for a reason one level up. What an R6 class guarantees is what its methods say rather than what its bindings happen to hold, so those bindings are not a value the package can record on the class's behalf, and the refusal names the class and the method it wants. A class author who has decided the bindings are the state opts in with that method, and r6_state() is the pair recording them. An R6 class generator needs no method either way: it is recorded by the class it names, and comes back the object it was written from.

Where the self_contained flag asks for it, a generator is recorded by what it binds instead, the way an environment is recorded by its contents, apart from what is R6's own rather than the class's: self and the functions R6 installs in every generator, its own clone among the methods included, which is whatever refers back to the generator. On the way back R6::R6Class() makes an empty generator, which supplies that part again, and the recorded bindings and attributes are put back into it. A document holding one therefore needs R6 where it is read but not the package that defined the class, a class no name finds again is carried rather than refused, and whatever the class binds comes back as it was. The parent is carried by the same rule rather than as the expression finding it, so a chain comes back whole, and the rebuilt class no longer follows a parent redefined after it was written. Unlike an S7 definition this one carries the methods, which an R6 class holds rather than registering them on a generic, so a class fixed or upgraded since the document was written does not reach the one it rebuilds. The environment those methods close over is recorded by the environment rule, so a namespace the reader cannot find is replaced by the global environment with a warning, and a method calling into a package that is gone fails where it is called. What comes back is a new generator rather than the one the name finds, so writing it again without the flag is refused.

A reference class instance is refused on the second half of that reason alone. Fields are declared there, so what the representation is already has an answer, but the object is still a reference whose initialize may establish an invariant and whose fields hold whatever a private binding would, so a method of your own is what settles it here as well, on the concrete class or on any class between that and envRefClass, which is where the refusal sits. The generator that makes one is refused rather than recorded, since a walk into it reaches the internals of the methods package rather than the class. An environment you have classed yourself claims none of this, and is written by the environment rule above, contents and all.

Examples

json_write_str(list(n = 1L, x = 2.5, missing = NA_character_))
#> [1] "{\"n\":1,\"x\":2.5,\"missing\":\"~zNA_character_\"}"

json_write_str(as.Date("2026-01-01"))
#> [1] "{\"~a\":{\"class\":\"Date\"},\"~v\":20454.0}"

json_write_str(quote(mpg ~ wt))
#> [1] "{\"~t\":\"language\",\"~v\":[\"~:~\",\"~:mpg\",\"~:wt\"]}"

json_write_str(stats::median)
#> [1] "{\"~t\":\"closure\",\"~v\":{\"formals\":{\"~t\":\"pairlist\",\"~v\":{\"x\":\"~:\",\"na.rm\":false,\"...\":\"~:\"}},\"body\":{\"~t\":\"language\",\"~v\":[\"~:UseMethod\",[\"median\"]]},\"environment\":{\"~t\":\"environment\",\"~v\":{\"name\":\"namespace:stats\"}}}}"

x <- c(a = 1, b = Inf)
identical(json_read_str(json_write_str(x)), x)
#> [1] TRUE

json_write_str(list(required = I("x"), additionalProperties = FALSE),
               typed = FALSE)
#> [1] "{\"required\":[\"x\"],\"additionalProperties\":false}"

# The constructor of a class defined in a package closes over that
# namespace, which a name finds again; `local()` stands in for it here.
Archived <- local(
  S7::new_class(
    "Archived", properties = list(n = S7::class_double),
    package = "somepkg"
  ),
  globalenv()
)

json_write_str(Archived(n = 1))
#> [1] "{\"~t\":\"object\",\"~a\":{\"class\":[\"somepkg::Archived\",\"S7_object\"],\"S7_class\":{\"~s7\":{\"class\":\"Archived\",\"package\":\"somepkg\"}},\"n\":1.0}}"

json_write_str(Archived(n = 1), self_contained = TRUE)
#> [1] "{\"~t\":\"object\",\"~a\":{\"class\":[\"somepkg::Archived\",\"S7_object\"],\"S7_class\":{\"~s7\":{\"class\":\"Archived\",\"package\":\"somepkg\",\"parent\":{\"~s7\":\"S7_object\"},\"properties\":{\"n\":{\"~a\":{\"class\":\"S7_property\"},\"~v\":{\"name\":\"n\",\"class\":{\"~s7\":\"class_double\"},\"getter\":null,\"setter\":null,\"validator\":null,\"default\":null}}},\"abstract\":false,\"constructor\":{\"~t\":\"closure\",\"~v\":{\"formals\":{\"~t\":\"pairlist\",\"~v\":{\"n\":{\"~t\":\"double\",\"~v\":[]}}},\"body\":{\"~t\":\"language\",\"~v\":[\"~:{\",\"~:n\",{\"~t\":\"language\",\"~v\":{\"\":{\"~t\":\"language\",\"~v\":[\"~:::\",\"~:S7\",\"~:new_object\"]},\"\":{\"~t\":\"language\",\"~v\":[{\"~t\":\"language\",\"~v\":[\"~:::\",\"~:S7\",\"~:S7_object\"]}]},\"n\":\"~:n\"}}]},\"environment\":{\"~t\":\"environment\",\"~v\":{\"name\":\"R_GlobalEnv\"}}}},\"validator\":null}},\"n\":1.0}}"