serde-untagged

A Serde Visitor for cleanly deserializing untagged enums from bools, numbers, strings, bytes, sequences, or maps.

Library
Cargo
v0.1.9
70stars
MIT OR Apache-2.0

Repository Health

Pre-computed score based on development activity, maintenance, community, maturity, and trend momentum.How we score it →
37/100Needs Attention
Development Activity24
Maintenance32
Community20
Maturity52
Momentum20

Technical Analysis

AI-assessed by reading the actual repository — architecture, code quality, innovation, and documentation.How we score it →
80/100Excellent
Architecture88
Code Quality95
Innovation72
Learning Curve65

serde-untagged provides a Visitor implementation that makes hand-written untagged-enum Deserialize impls tractable in Rust. Instead of writing a full serde::de::Visitor with fifteen-plus boilerplate methods, you build an UntaggedEnumVisitor by chaining only the input shapes your type actually accepts — .bool(), .i64(), .string(), .seq(), .map(), and so on — each given a closure that converts the matched value into your enum variant.

The crate is written by dtolnay (author of serde itself) and is built around the exact real-world case that motivates untagged enums: config values that can be written multiple ways, such as Cargo’s own profile.release.lto setting accepting either a bool or one of a few string variants, or http.ssl-version accepting either a bare string or a nested table. It is #![no_std], has no macro-generated code, and produces accurate auto-generated error messages (“invalid type: null, expected a boolean, integer or array”) describing exactly which shapes were registered.

What You Get

  • UntaggedEnumVisitor::new() builder with one optional, chainable method per Serde value kind (bool, i8-i128, u8-u128, f32/f64, char, string, borrowed_str, bytes, borrowed_bytes, byte_buf, none, unit, seq, map)
  • Automatic integer-kind widening/narrowing so a closure registered for one integer width still matches values reported as a different width
  • Typed Map/Seq handles passed into your closures (map.deserialize::<T>(), seq.next_element()), so nested types can reuse their own Deserialize impls instead of manually walking the accessor
  • Auto-generated expecting() error messages built from whichever value kinds were registered, or a fully custom message via .expecting(...)
  • No macro codegen — pure runtime builder over serde’s Deserializer/Visitor traits, works with any format (JSON, TOML, YAML, etc.)

Common Use Cases

  • Config fields that accept either a scalar shorthand or a nested table, mirroring Cargo’s own ssl-version/lto settings
  • API response enums where one variant is a bare string (e.g. an error code) and another is a full JSON object
  • Enums that need to accept both an owned and array form of the same underlying value, such as a single string vs. a list of strings
  • Any hand-rolled untagged Deserialize impl where #[serde(untagged)] derive’s ambiguity errors or performance cost are unacceptable

Under The Hood

Architecture The crate centers on UntaggedEnumVisitor<'closure, 'de, Value> in src/lib.rs, a struct holding one Option<Box<dyn FnOnce(..) -> Result<Value, Error> + 'closure>> slot per Serde primitive kind; each builder method (.bool(), .string(), .map(), etc.) fills exactly one slot and panics if called twice, and .deserialize() simply calls deserializer.deserialize_any(self), letting the struct’s own Visitor impl dispatch to whichever closure matches the value actually encountered, falling back to a DefaultVisitor (built on serde’s Expected trait) when nothing matches so error messages stay accurate. Integer values flow through a priority-ordered dispatch_integer helper (src/int.rs) so, for example, a u8 can still satisfy a closure registered only for i64 via safe widening. Map and Seq (src/map.rs, src/seq.rs) wrap the underlying MapAccess/SeqAccess behind an object-safe erasure trait backed by ErasedValue (src/any.rs) — a hand-rolled type-erased box storing a raw pointer plus a type-erased drop function, with debug/miri-only TypeId assertions guarding against misuse — since serde’s own DeserializeSeed/Deserializer traits aren’t object-safe across arbitrary types. A parallel Error type (src/error.rs) implements serde::de::Error and stores enough structured state to convert back into whatever concrete error type the caller’s real Deserializer expects.

Tech Stack A #![no_std] crate (extern crate alloc) depending on erased-serde 0.4.2 and serde_core 1.0.220 (both default-features = false, features = ["alloc"], serde_core aliased internally as serde), plus typeid 1 for the debug-mode type-id check in the erasure code. A phantom [target.'cfg(any())'.dependencies] entry pulls in full serde only so downstream cargo add also adds it, without it being a real build dependency. Dev-dependencies (serde, serde_derive, serde_json) exist solely for the test suite. rust-version = "1.68", edition 2021, dual-licensed MIT OR Apache-2.0, no build script and no native dependencies.

Code Quality tests/test.rs covers a string-or-array untagged enum, a borrowed-string variant, a map-buffered response type, and a dedicated expecting() message-formatting test with multiple assertions; tests/crate/ is a separate minimal crate used only for the MSRV cargo check. CI runs cargo test across nightly/beta/stable/1.76.0, a pinned MSRV job on Rust 1.68.0, a minimal-versions job, cargo miri test with strict-provenance flags (directly exercising the crate’s manual unsafe pointer erasure), cargo clippy --tests -Dclippy::all -Dclippy::pedantic, a docs.rs build check, and a cargo outdated job — an unusually thorough matrix for a crate this size. The unsafe blocks are isolated entirely to any.rs, gated by #![deny(unsafe_op_in_unsafe_fn)], and backed by debug/miri-only TypeId assertions; the public API surface itself is fully safe.

API Design The entire public surface is one type: UntaggedEnumVisitor::new().<kind>(closure)...deserialize(deserializer), where every combinator method is optional, chainable, and documented with a runnable doctest, so callers implement only the branches their format actually needs rather than a full multi-method Visitor impl. .expecting() lets a caller override the auto-generated error message, and Map/Seq closures receive typed handles rather than raw accessors so nested Deserialize impls can be reused directly. It is a narrow, single-purpose helper rather than a general-purpose abstraction — the value is entirely in collapsing serde’s verbose low-level Visitor trait into a small fluent builder for one specific, common real-world problem.

Join founders buildingwith open source

Opinionated takes, migration guides, cost-saving tips, and insights from the open source ecosystem.

Subscribe on Substack
Join 750+ subscribers

Search