Reflection-driven serialization, deserialization, and data transformation for Mojo.
Inspired by reflect-cpp, zero-boilerplate struct serde using compile-time reflection.
Mojo structs don't serialize out of the box. morph uses compile-time reflection to automatically map struct fields to/from JSON, CSV, TOML, YAML, and CLI arguments (no manual to_json() or from_json() methods needed).
from morph import write, read @fieldwise_init struct Person(Defaultable, Movable): var name: String var age: Int var active: Bool def __init__(out self): self.name = "" self.age = 0 self.active = False def main() raises: var p = Person(name="Alice", age=30, active=True) print(write(p)) # {"name":"Alice","age":30,"active":true} var q = read[Person]('{"name":"Bob","age":25,"active":false}') print(q.name) # Bob
pixi package manager
Add morph to your project's pixi.toml:
[workspace] channels = ["https://conda.modular.com/max-nightly", "conda-forge"] preview = ["pixi-build"] [dependencies] morph = { git = "https://github.com/ehsanmok/morph.git", tag = "v0.1.1" }
Then run:
pixi install
For the latest development version:
[dependencies] morph = { git = "https://github.com/ehsanmok/morph.git", branch = "main" }
| Type | JSON | CSV | CLI | TOML | YAML |
|---|---|---|---|---|---|
Int, Int64 |
yes | yes | yes | yes | yes |
Bool |
yes | yes | yes (flag) | yes | yes |
Float64, Float32 |
yes | yes | yes | yes | yes |
String |
yes | yes | yes | yes | yes |
Optional[T] |
yes (null) | no | yes | yes (omit if None) | yes (null) |
List[T] |
yes | no | yes (comma) | yes (arrays) | yes (sequences) |
| Nested structs | yes | no | yes (dot-notation) | yes (tables) | yes (indented) |
| Custom traits | yes | no | no | no | no |
Where T is one of Int, String, Float64, Bool.
CSV limitations (inherent to the format): CSV is flat/tabular: Optional needs an empty-string convention, List needs delimiter sub-fields, nested structs need column flattening.
- Zero boilerplate: works on any struct via compile-time reflection
- Round-trip safe:
read(write(x))preserves data - Custom serde: implement
Serializable/Deserializableto override - Pretty print:
write[pretty=True](value)for formatted output - Rich errors: type mismatch, missing field, invalid JSON
Convert between naming conventions at the serde boundary:
var json = write[rename="camelCase"](my_struct) var obj = read[MyStruct, rename="camelCase"](json)
Supported: camelCase, PascalCase, SCREAMING_SNAKE, none (default).
# Skip fields starting with underscore var json = write[skip_private=True](value) # Add type discriminator field var json = write[add_type=True](value) # {"_type":"MyStruct",...} # Serialize as array (no field names) var json = write[as_array=True](value) # [1,"hello",true] # Default missing fields instead of raising var obj = read[MyStruct, default_if_missing=True](json) # Strict mode: reject unknown keys var obj = read[MyStruct, strict=True](json) # Reject null on Optional fields var obj = read[MyStruct, no_optionals=True](json)
from morph import fields, field_names, as_type, replace, replace_int var info = fields[Person]() # List[FieldInfo] with name/type var names = field_names[Person]() # List[String] # Convert between struct types (copies matching fields) var employee = as_type[Employee](person) # Copy with one field changed var updated = replace[Person, "name"](person, "Bob") var older = replace_int[Person, "age"](person, 31)
Runtime validators return Optional[ValidationError]:
from morph import check_min, check_max, check_range, check_exclusive_min, check_exclusive_max, check_non_empty, check_min_length, check_max_length, check_one_of, raise_if_errors var errors = List[ValidationError]() var e1 = check_min(config.age, 0, "age") # age >= 0 if e1: errors.append(e1.value().copy()) var e2 = check_exclusive_max(config.age, 150, "age") # age < 150 if e2: errors.append(e2.value().copy()) var e3 = check_non_empty(config.name, "name") if e3: errors.append(e3.value().copy()) var e4 = check_one_of(config.status, allowed, "status") # enum-like if e4: errors.append(e4.value().copy()) raise_if_errors(errors)
Enum-like validation: Mojo uses structs instead of C++ enums. Use check_one_of
with a list of allowed strings to validate enum-like values.
from morph import json_schema var schema = json_schema[Config]() var schema_titled = json_schema[Config, title="AppConfig"]() var schema_renamed = json_schema[Config, rename="camelCase"]()
Generates Draft 2020-12 compatible schema with type, properties, required.
Add descriptions and deprecated markers:
from morph import json_schema_described from std.collections import Dict var descriptions = Dict[String, String]() descriptions["host"] = "Server hostname" descriptions["port"] = "Server port" descriptions["_deprecated"] = "log_level" # Mark field as deprecated var schema = json_schema_described[Config](descriptions)
Serialize/deserialize nested structs with their fields at the parent level:
from morph import write_flat, read_flat @fieldwise_init struct Address(Defaultable, Movable): var street: String var city: String def __init__(out self): self.street = "" self.city = "" @fieldwise_init struct Person(Defaultable, Movable): var name: String var address: Address def __init__(out self): self.name = "" self.address = Address() var p = Person(name="Alice", address=Address(street="123 Main", city="NYC")) var json = write_flat(p) # {"name":"Alice","street":"123 Main","city":"NYC"} var restored = read_flat[Person](json)
Parse command-line arguments directly into a struct:
from morph import parse_args, usage @fieldwise_init struct Config(Defaultable, Movable): var host: String var port: Int var verbose: Bool var tags: List[String] var label: Optional[String] def __init__(out self): self.host = "localhost" self.port = 8080 self.verbose = False self.tags = List[String]() self.label = None def main() raises: var args = List[String]("-v", "--host", "0.0.0.0", "--port", "9090", "--tags", "web,api") var config = parse_args[Config](args) print(usage[Config]())
- Underscore fields become hyphenated flags:
max_retries->--max-retries - Bool fields are flags (no value needed):
--verboseor-v - Short flags: first letter of field name (
-pforport,-hforhost) Optional[T]fields are non-required (default to None if omitted)List[T]fields accept comma-separated values:--tags=web,api,prod- Other types require a value:
--port 9090
Nested structs with dot-notation:
from morph import parse_args_nested @fieldwise_init struct ServerConfig(Defaultable, Movable): var host: String var port: Int def __init__(out self): self.host = "localhost" self.port = 8080 @fieldwise_init struct AppConfig(Defaultable, Movable): var debug: Bool var server: ServerConfig def __init__(out self): self.debug = False self.server = ServerConfig() var args = List[String]("--debug", "--server.host", "0.0.0.0", "--server.port", "9090") var config = parse_args_nested[AppConfig](args) # config.server.host == "0.0.0.0", config.server.port == 9090
Positional arguments (non-flag args assigned to String fields in order):
from morph import parse_args_positional var args = List[String]("input.txt", "output.txt", "--verbose") var config = parse_args_positional[CmdArgs](args) # config.source == "input.txt", config.dest == "output.txt", config.verbose == True
from morph import to_csv, from_csv, csv_header, to_csv_row var csv = to_csv(record) # header + data row var rows = from_csv[Record](csv_string) # parse CSV to List[Record]
- Auto-generates header from field names
- Handles quoted fields (commas, newlines, double quotes)
- Multi-row serialization with
to_csv_multi
from morph.toml import to_toml, from_toml var toml = to_toml(config) var cfg = from_toml[Config](toml_str)
- Scalars, Optional, List, nested structs (as TOML tables)
- Optional fields omitted when
None - Lists serialized as inline TOML arrays
- Supports field renaming (
renameparam)
from morph.yaml import to_yaml, from_yaml var yaml = to_yaml(person) var p = from_yaml[Person](yaml_str)
- Indentation-based YAML subset (no anchors, aliases, or tags)
- Block sequences for lists, indented mappings for nested structs
- Optional fields serialize as
nullwhenNone - Handles
yes/no/on/offas bool,~as null - Supports field renaming (
renameparam)
Extensible format system for additional formats:
from morph.format import FormatBackend struct MyFormat(FormatBackend): def serialize[T: AnyType](self, value: T) raises -> String: ... def deserialize[T: Morphable](self, data: String) raises -> T: ... def file_extension(self) -> String: ...
Full API reference: ehsanmok.github.io/morph
git clone https://github.com/ehsanmok/morph.git && cd morph pixi install pixi run tests
pixi run tests # Run all 215 tests + examples pixi run test-serialize # Run serialize tests only pixi run test-deserialize pixi run test-roundtrip pixi run test-edge-cases pixi run test-reflect pixi run test-rename pixi run test-transform # Rename, skip_private, transform, defaults pixi run test-validate # Validation, JSON Schema pixi run test-processors # Processors integration (add_type, strict, as_array) pixi run test-cli-csv # CLI parsing, CSV serde, string validators pixi run test-new-features # Exclusive validators, replace, CLI Optional/List/short pixi run test-toml-yaml # TOML and YAML serde (24 tests) pixi run examples # Run all 11 examples pixi run example-basic # 01: Basic struct serde pixi run example-nested # 02: Nested structs pixi run example-optional # 03: Optional and List fields pixi run example-custom # 04: Custom Serializable/Deserializable traits pixi run example-rename # 05: Field renaming strategies pixi run example-transform # 06: Introspection, serde options, as_type pixi run example-validate # 07: Validation and JSON Schema pixi run example-cli # 08: CLI argument parsing pixi run example-csv # 09: CSV serialization/deserialization pixi run example-toml # 10: TOML serialization/deserialization pixi run example-yaml # 11: YAML serialization/deserialization pixi run format # Format code pixi run docs # Generate and open API docs