This package provides an easy way to write out structs as YAML/JSON and also to load YAML/JSON and populate the appropriate struct.
PortableStructs is intended for trusted configuration and data files. Loading a typed file resolves type/function names from Julia modules and can call constructors or functions, so it should not be used as a safe deserializer for untrusted input.
It is easy to write (most) structs-of-structs out to a YAML file:
import PortableStructs
import YAML # Activates PortableStructs' YAML extension.
PortableStructs.write_to_yaml("file.yaml", my_struct)
It is similarly easy to load from YAML:
import PortableStructs
import YAML
my_struct = PortableStructs.load_from_yaml("file.yaml")
The loaded structure will in general have the same native Julia types as the original.
Where the type you wish to load as is known, that can be provided:
import PortableStructs
import YAML
my_struct = PortableStructs.load_from_yaml("file.yaml", MyType)
Here's an example.
@enum Status DoingWell DoingPoorly
@kwdef struct Position{T}
x::T
y::T
z::T
end
@kwdef struct MyType
name::String
position::Position{Float64}
status::Status
end
x = MyType("My Name", Position(1., 2., 3.), DoingWell)
import PortableStructs
import YAML
PortableStructs.write_to_yaml("my_struct.yaml", x)
Here's what the YAML looks like:
type: "MyType"
name: "My Name"
position:
type: "Position"
x: 1.0
y: 2.0
z: 3.0
status: "DoingWell"
We can load that back in like so:
import PortableStructs
import YAML
y = PortableStructs.load_from_yaml("my_struct.yaml")
giving:
MyType("My Name", Position{Float64}(1.0, 2.0, 3.0), DoingWell)
The type can be a type or a function to call with keyword arguments.
YAML and JSON sequences normally load as vectors when the containing field does not provide
a more specific type. A dictionary can explicitly identify a tuple and provide its values
under args:
size:
type: Tuple
args: [1024, 768]Each entry under args is recursively decoded, so it can also contain tagged values. This
representation is intended for hand-authored inputs; ordinary tuple serialization remains a
plain sequence.
The same dictionary representation from above can be written to JSON. The JSON methods are available once JSON is loaded:
import PortableStructs
import JSON
PortableStructs.write_to_json("my_struct.json", x)
y = PortableStructs.load_from_json("my_struct.json")
If the desired output type is known, pass it as the second argument:
y = PortableStructs.load_from_json("my_struct.json", MyType)
A YAML file can be "included" at any level. This allows the user to break up a large YAML file into smaller ones. By default, the key include will be used to indicate what file to include. The include_key keyword argument to load_from_yaml can specify a different key to use (e.g., _include). When including files, the file name is assumed to be relative to the file that has the "include" in it (or an absolute path).
Includes can also provide except entries to overwrite values from the included file. Exception paths use dot-separated dictionary keys. They can also target existing vector elements with 1-based indices, matching Julia's indexing. For example, trees[2].common_name overwrites the common_name key in the second element of the trees vector. Vector indices must already exist; exceptions do not append to vectors or create missing vector entries.
For example, trees.yaml might provide shared data:
trees:
- scientific_name: Arbutus unedo
common_name: strawberry tree
- scientific_name: Arbutus menziesii
common_name: madrona
notes: Needs review.Another file can include it and overwrite selected values:
include:
source: trees.yaml
except:
- path: trees[2].common_name
value: Pacific madrone
- path: notes
value: Reviewed.Loading the second file produces the included data with the second tree's common name changed and the top-level notes value replaced. The trees[2] path uses Julia-style 1-based indexing.
PortableStructs writes type names and resolves type names relative to a base_module. By
default, base_module = Main, which is a good match for scripts and REPL work where the
types being loaded are available from Main.
Package code that writes its own artifacts can choose the package module as the base module
instead. Use the same base_module when loading as was used when writing.
Values can also use types from modules outside the chosen base_module. In that case,
PortableStructs writes the type's defining module path, such as Dates.Date. When loading,
it first tries to resolve names relative to base_module. If the first name is not present
there, the loader also accepts explicit roots like Main, Base, and Core, or a module
binding with that name in Main. This means a file written with a given base_module and a
given set of imported modules in Main should be loaded with the same base_module and the
same imports available.
module MyPackage
using PortableStructs
@kwdef struct Config
gain::Float64
end
function save_config(file, config)
PortableStructs.write_to_yaml(file, config; base_module = @__MODULE__)
return nothing
end
function load_config(file)
return PortableStructs.load_from_yaml(file; base_module = @__MODULE__)
end
end
With base_module = @__MODULE__, the YAML can use a package-relative type tag:
type: Config
gain: 1.0Without that explicit base_module, the default Main-relative representation would need
to name a type that can be resolved from Main.
For example, Dates.Date can round-trip through another base module as long as Dates is
imported when both writing and loading:
import Dates
import PortableStructs
import YAML
module C
end
x = Dates.Date(2026, 6, 24)
PortableStructs.write_to_yaml("date.yaml", x; base_module = C)
PortableStructs.load_from_yaml("date.yaml"; base_module = C)
This package is meant to be simple, and that simplicity comes from several constraints:
- The user's structs will be constructed either from keyword arguments or from positional arguments. For positional arguments, the YAML/JSON file should have a key matching each field name, and the arguments will be provided to the constructor in the order of the field names (not in the order in which they're encountered in the YAML/JSON file).
- The type of each struct will show up in the YAML/JSON file with a key called "type" (or whatever string is specified by the
type_keykeyword argument towrite_to_yamlandload_from_yaml). Hence no struct is allowed have a field with this name. - This isn't meant to be fast or efficient.
There is overlap with the functionality in StructTypes, and that package is more mature than this with far more support in the package ecosystem. However, it's simpler to make an arbitrary struct work with this package (generally, the user need not do anything at all) than with StructTypes, even for fields with abstract types.
YAML and JSON support are provided by package extensions. This means PortableStructs itself does not depend directly on either parser. Load YAML before calling load_from_yaml or write_to_yaml, and load JSON before calling load_from_json or write_to_json.
PortableStructs has two main customization hooks.
PortableStructs.to_dict(v; type_key, kwargs...) converts a Julia value into the plain Julia data that a file-format extension can write: scalars, vectors, and dictionaries with string keys. Extend this when a type should have a more compact or semantic representation than "all fields plus a type tag". For example, a data-backed object might write only a filename.
PortableStructs.from_dict(::Type{T}, value; type_key, base_module, kwargs...) converts
parsed data back into T. Extend this for types you own when the generic construction path
is not right. For types owned by other packages, prefer a small adapter package or package
extension rather than adding broad methods in reusable libraries.
The parser-specific dictionary functions live at the file-format boundary:
PortableStructs.load_yaml_dict(filename; include_key = "include")PortableStructs.write_yaml_dict(filename, dict)PortableStructs.load_json_dict(filename; include_key = "include")PortableStructs.write_json_dict(filename, dict; indent = 4)
These functions are implemented by the YAML and JSON extensions. They are useful if you want to work directly at the dictionary layer, or if you are writing another format extension and want to mirror the same pattern: parse a file into dictionaries, let PortableStructs expand includes and construct values, then write dictionaries back out.
- The key reason this package exists, instead of just using StructTypes, is that this handles abstract types where the potential subtypes of the abstract type aren't known (one can't write a
StructTypes.subtypesfunction to resolve which abstract type should be constructed).