What is the purpose of serde_with::rust::StringWithSeparator for parsing delimited string fields?
serde_with::rust::StringWithSeparator bridges the gap between delimited string representations (like "a,b,c") and Rust collections (Vec<String>), enabling automatic parsing during deserialization and joining during serializationācrucial for APIs, configuration files, and databases that store arrays as delimited strings. It handles separator configuration, empty string edge cases, and integrates seamlessly with serde's derive macros.
Basic StringWithSeparator Usage
use serde::{Deserialize, Serialize};
use serde_with::rust::StringWithSeparator;
#[derive(Debug, Serialize, Deserialize)]
struct QueryParams {
#[serde_as(as = "StringWithSeparator::<CommaSeparator>")]
tags: Vec<String>,
}
// This module is needed for the separator
use serde_with::formats::{CommaSeparator, SpaceSeparator, SemicolonSeparator};
fn basic_usage() {
// Deserialize from delimited string
let json = r#"{"tags": "rust,programming,web"}"#;
let params: QueryParams = serde_json::from_str(json).unwrap();
println!("{:?}", params.tags);
// ["rust", "programming", "web"]
// Serialize back to delimited string
let output = serde_json::to_string(¶ms).unwrap();
println!("{}", output);
// {"tags":"rust,programming,web"}
}StringWithSeparator transforms between delimited strings and Vec types.
Separator Configuration
use serde::{Deserialize, Serialize};
use serde_with::rust::StringWithSeparator;
use serde_with::formats::{CommaSeparator, SpaceSeparator, SemicolonSeparator, ColonSeparator};
#[derive(Debug, Serialize, Deserialize)]
struct Config {
// Comma-separated (most common)
#[serde_as(as = "StringWithSeparator::<CommaSeparator>")]
items: Vec<String>,
// Space-separated
#[serde_as(as = "StringWithSeparator::<SpaceSeparator>")]
words: Vec<String>,
// Semicolon-separated
#[serde_as(as = "StringWithSeparator::<SemicolonSeparator>")]
commands: Vec<String>,
// Colon-separated
#[serde_as(as = "StringWithSeparator::<ColonSeparator>")]
paths: Vec<String>,
}
fn separator_types() {
let config = Config {
items: vec
!["a".to_string(), "b".to_string()]
,
words: vec
!["hello".to_string(), "world".to_string()]
,
commands: vec
!["ls".to_string(), "pwd".to_string()]
,
paths: vec
!["usr".to_string(), "bin".to_string()]
,
};
let json = serde_json::to_string(&config).unwrap();
println!("{}", json);
// {"items":"a,b","words":"hello world","commands":"ls;pwd","paths":"usr:bin"}
}Multiple separator types are available for different use cases.
Using serde_as Attribute
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
use serde_with::rust::StringWithSeparator;
use serde_with::formats::CommaSeparator;
// The serde_as attribute enables StringWithSeparator
#[serde_as]
#[derive(Debug, Serialize, Deserialize)]
struct ApiRequest {
#[serde_as(as = "StringWithSeparator::<CommaSeparator>")]
ids: Vec<i32>, // Works with non-String types too!
#[serde_as(as = "Option<StringWithSeparator::<CommaSeparator>>")]
optional_tags: Option<Vec<String>>,
}
fn serde_as_attribute() {
let json = r#"{"ids": "1,2,3,4,5", "optional_tags": "tag1,tag2"}"#;
let request: ApiRequest = serde_json::from_str(json).unwrap();
println!("{:?}", request.ids);
// [1, 2, 3, 4, 5] - parsed as integers!
println!("{:?}", request.optional_tags);
// Some(["tag1", "tag2"])
}The serde_as attribute enables powerful type conversions.
Working with Different Types
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
use serde_with::rust::StringWithSeparator;
use serde_with::formats::CommaSeparator;
#[serde_as]
#[derive(Debug, Serialize, Deserialize)]
struct FilterParams {
// Parse integers from string
#[serde_as(as = "StringWithSeparator::<CommaSeparator>")]
ids: Vec<u64>,
// Parse booleans
#[serde_as(as = "StringWithSeparator::<CommaSeparator>")]
flags: Vec<bool>,
// Nested: comma-separated pairs of integers
#[serde_as(as = "StringWithSeparator::<CommaSeparator>")]
coordinates: Vec<i32>,
}
fn different_types() {
let json = r#"{
"ids": "1,2,3,4,5",
"flags": "true,false,true",
"coordinates": "10,20,30,40"
}"#;
let params: FilterParams = serde_json::from_str(json).unwrap();
assert_eq!(params.ids, vec
![1, 2, 3, 4, 5])
;
assert_eq!(params.flags, vec
![true, false, true])
;
assert_eq!(params.coordinates, vec
![10, 20, 30, 40])
;
}StringWithSeparator works with any type that implements Deserialize/Serialize.
Handling Empty Strings
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
use serde_with::rust::StringWithSeparator;
use serde_with::formats::CommaSeparator;
#[serde_as]
#[derive(Debug, Serialize, Deserialize)]
struct SearchParams {
#[serde_as(as = "StringWithSeparator::<CommaSeparator>")]
tags: Vec<String>,
}
fn empty_string_handling() {
// Empty string -> empty Vec
let json = r#"{"tags": ""}"#;
let params: SearchParams = serde_json::from_str(json).unwrap();
println!("{:?}", params.tags);
// [] - empty string becomes empty Vec
// Empty Vec -> empty string
let params = SearchParams { tags: vec
![] }
;
let json = serde_json::to_string(¶ms).unwrap();
println!("{}", json);
// {"tags":""}
// Whitespace-only strings
let json = r#"{"tags": " "}"#;
let params: SearchParams = serde_json::from_str(json).unwrap();
// Behavior depends on configuration
}Empty strings become empty vectors by default.
Deserialization in Detail
use serde::de::{Deserialize, Deserializer};
use serde_with::rust::StringWithSeparator;
use serde_with::formats::CommaSeparator;
fn deserialization_process() {
// During deserialization:
// 1. String is read from input
// 2. String is split by separator
// 3. Each part is trimmed (depending on config)
// 4. Each part is parsed into target type
let input = "apple, banana , cherry";
// Split by comma -> ["apple", " banana ", " cherry"]
// Trim whitespace -> ["apple", "banana", "cherry"]
// Parse each -> ["apple", "banana", "cherry"]
// For numbers:
let input = "1, 2, 3";
// Split -> ["1", " 2", " 3"]
// Trim -> ["1", "2", "3"]
// Parse -> [1, 2, 3]
}The deserialization splits, trims, and parses each element.
Serialization in Detail
use serde::ser::{Serialize, Serializer};
use serde_with::rust::StringWithSeparator;
use serde_with::formats::CommaSeparator;
fn serialization_process() {
// During serialization:
// 1. Each element is converted to string
// 2. Elements are joined with separator
// 3. Result is written to output
let items = vec
!["a", "b", "c"]
;
// Convert to strings -> ["a", "b", "c"]
// Join with comma -> "a,b,c"
// For numbers:
let ids = vec
![1, 2, 3]
;
// Convert to strings -> ["1", "2", "3"]
// Join -> "1,2,3"
}Serialization converts each element to a string and joins them.
Custom Separators
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
use serde_with::Separator;
// Define a custom separator
struct PipeSeparator;
impl Separator for PipeSeparator {
fn separator() -> &'static str {
"|"
}
}
#[serde_as]
#[derive(Debug, Serialize, Deserialize)]
struct PipeDelimited {
#[serde_as(as = "StringWithSeparator::<PipeSeparator>")]
values: Vec<String>,
}
fn custom_separator() {
let data = PipeDelimited {
values: vec
!["first".to_string(), "second".to_string(), "third".to_string()]
,
};
let json = serde_json::to_string(&data).unwrap();
println!("{}", json);
// {"values":"first|second|third"}
// Parse back
let parsed: PipeDelimited = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.values, vec
!["first", "second", "third"])
;
}Custom separators can be defined for any delimiter.
Handling Special Characters
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
use serde_with::rust::StringWithSeparator;
use serde_with::formats::CommaSeparator;
#[serde_as]
#[derive(Debug, Serialize, Deserialize)]
struct DataWithSpecial {
#[serde_as(as = "StringWithSeparator::<CommaSeparator>")]
items: Vec<String>,
}
fn special_characters() {
// Strings containing commas (not handled automatically!)
let json = r#"{"items": "hello,world,test"}"#;
let data: DataWithSpecial = serde_json::from_str(json).unwrap();
// Parsed as ["hello", "world", "test"]
// NOT "hello,world" as one item
// If data contains the separator:
let problem = DataWithSpecial {
items: vec
!["a,b".to_string(), "c".to_string()]
, // "a,b" contains comma!
};
let json = serde_json::to_string(&problem).unwrap();
println!("{}", json);
// {"items":"a,b,c"}
// Parsing back gives ["a", "b", "c"] - WRONG!
// Solution: escape or use different separator
// Or use proper CSV/TSV handling
}Embedded separators require escaping or alternative approaches.
Optional Fields
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
use serde_with::rust::StringWithSeparator;
use serde_with::formats::CommaSeparator;
#[serde_as]
#[derive(Debug, Serialize, Deserialize)]
struct OptionalParams {
#[serde_as(as = "Option<StringWithSeparator::<CommaSeparator>>")]
tags: Option<Vec<String>>,
#[serde_as(as = "StringWithSeparator::<CommaSeparator>")]
#[serde(default)]
items: Vec<String>,
}
fn optional_fields() {
// Present tags
let json = r#"{"tags": "a,b,c"}"#;
let params: OptionalParams = serde_json::from_str(json).unwrap();
assert_eq!(params.tags, Some(vec
!["a", "b", "c"]))
;
// Missing tags
let json = r#"{}"#;
let params: OptionalParams = serde_json::from_str(json).unwrap();
assert_eq!(params.tags, None);
// Empty tags
let json = r#"{"tags": ""}"#;
let params: OptionalParams = serde_json::from_str(json).unwrap();
assert_eq!(params.tags, Some(vec
![]))
; // Empty Vec, not None
// Null tags
let json = r#"{"tags": null}"#;
let params: OptionalParams = serde_json::from_str(json).unwrap();
assert_eq!(params.tags, None);
}Option<StringWithSeparator> handles missing/null values.
Query String Parsing
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
use serde_with::rust::StringWithSeparator;
use serde_with::formats::CommaSeparator;
// Common use case: URL query parameters
#[serde_as]
#[derive(Debug, Deserialize)]
struct SearchQuery {
#[serde_as(as = "StringWithSeparator::<CommaSeparator>")]
#[serde(rename = "q")]
query: Vec<String>,
#[serde_as(as = "Option<StringWithSeparator::<CommaSeparator>>")]
#[serde(rename = "tags")]
filter_tags: Option<Vec<String>>,
#[serde_as(as = "StringWithSeparator::<CommaSeparator>")]
#[serde(rename = "ids", default)]
ids: Vec<i64>,
}
fn query_string_example() {
// URL: /search?q=rust,web,tags=programming&ids=1,2,3
// Parse as query string (pseudo-code)
let query: SearchQuery = serde_urlencoded::from_str(
"q=rust,web&tags=programming&ids=1,2,3"
).unwrap();
println!("{:?}", query.query); // ["rust", "web"]
println!("{:?}", query.filter_tags); // Some(["programming"])
println!("{:?}", query.ids); // [1, 2, 3]
}StringWithSeparator is ideal for comma-separated query parameters.
Configuration File Parsing
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
use serde_with::rust::StringWithSeparator;
use serde_with::formats::{CommaSeparator, SemicolonSeparator};
#[serde_as]
#[derive(Debug, Deserialize)]
struct AppConfig {
// YAML: allowed_origins: "localhost,example.com,api.example.com"
#[serde_as(as = "StringWithSeparator::<CommaSeparator>")]
allowed_origins: Vec<String>,
// TOML: admin_emails = "admin@example.com;root@example.com"
#[serde_as(as = "StringWithSeparator::<SemicolonSeparator>")]
admin_emails: Vec<String>,
// JSON: "features": "feature1,feature2,feature3"
#[serde_as(as = "StringWithSeparator::<CommaSeparator>")]
features: Vec<String>,
}
fn config_example() {
let yaml = r#"
allowed_origins: "localhost,example.com,api.example.com"
admin_emails: "admin@example.com;root@example.com"
features: "auth,logging,metrics"
"#;
let config: AppConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.allowed_origins, vec
!["localhost", "example.com", "api.example.com"])
;
assert_eq!(config.admin_emails, vec
!["admin@example.com", "root@example.com"])
;
}Configuration files often use delimited strings for arrays.
Database Interaction
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
use serde_with::rust::StringWithSeparator;
use serde_with::formats::CommaSeparator;
#[serde_as]
#[derive(Debug, Serialize, Deserialize)]
struct UserRecord {
id: i64,
name: String,
// Database stores comma-separated role IDs
#[serde_as(as = "StringWithSeparator::<CommaSeparator>")]
role_ids: Vec<i64>,
// Database stores comma-separated permissions
#[serde_as(as = "StringWithSeparator::<CommaSeparator>")]
permissions: Vec<String>,
}
fn database_example() {
// From database row (pseudo-code)
// role_ids column: "1,2,3"
// permissions column: "read,write,delete"
let row = UserRecord {
id: 1,
name: "Alice".to_string(),
role_ids: vec
![1, 2, 3]
,
permissions: vec
!["read".to_string(), "write".to_string(), "delete".to_string()]
,
};
// Serialize for database
let json = serde_json::to_string(&row).unwrap();
println!("{}", json);
// {"id":1,"name":"Alice","role_ids":"1,2,3","permissions":"read,write,delete"}
// Parse from database
let parsed: UserRecord = serde_json::from_str(&json).unwrap();
println!("{:?}", parsed.role_ids); // [1, 2, 3]
println!("{:?}", parsed.permissions); // ["read", "write", "delete"]
}Databases often store arrays as delimited strings.
Combining with Other serde_with Features
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
use serde_with::rust::StringWithSeparator;
use serde_with::formats::CommaSeparator;
#[serde_as]
#[derive(Debug, Serialize, Deserialize)]
struct CombinedExample {
// StringWithSeparator + DisplayFromStr
#[serde_as(as = "StringWithSeparator::<CommaSeparator>")]
numbers: Vec<i32>,
// Optional + StringWithSeparator
#[serde_as(as = "Option<StringWithSeparator::<CommaSeparator>>")]
optional_items: Option<Vec<String>>,
// Default + StringWithSeparator
#[serde_as(as = "StringWithSeparator::<CommaSeparator>")]
#[serde(default)]
default_items: Vec<String>,
// Skip serializing if empty
#[serde_as(as = "StringWithSeparator::<CommaSeparator>")]
#[serde(skip_serializing_if = "Vec::is_empty")]
skip_if_empty: Vec<String>,
}
fn combined_features() {
let data = CombinedExample {
numbers: vec
![1, 2, 3]
,
optional_items: Some(vec
!["a".to_string(), "b".to_string()]
),
default_items: vec
![]
,
skip_if_empty: vec
![]
,
};
let json = serde_json::to_string(&data).unwrap();
println!("{}", json);
// {"numbers":"1,2,3","optional_items":"a,b","default_items":""}
// skip_if_empty is not present because it's empty
}StringWithSeparator combines with other serde features.
Comparison with Manual Parsing
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
use serde_with::rust::StringWithSeparator;
use serde_with::formats::CommaSeparator;
// Manual parsing approach
#[derive(Debug, Deserialize)]
struct ManualParse {
tags: String,
}
impl ManualParse {
fn get_tags(&self) -> Vec<String> {
if self.tags.is_empty() {
return vec
![];
}
self.tags.split(',')
.map(|s| s.trim().to_string())
.collect()
}
}
// StringWithSeparator approach
#[serde_as]
#[derive(Debug, Deserialize)]
struct AutoParse {
#[serde_as(as = "StringWithSeparator::<CommaSeparator>")]
tags: Vec<String>,
}
fn comparison() {
let json = r#"{"tags": "rust,web,async"}"#;
// Manual
let manual: ManualParse = serde_json::from_str(json).unwrap();
let tags = manual.get_tags();
println!("{:?}", tags);
// Automatic
let auto: AutoParse = serde_json::from_str(json).unwrap();
println!("{:?}", auto.tags);
// StringWithSeparator benefits:
// 1. No manual parsing code
// 2. Consistent behavior across types
// 3. Automatic serialization
// 4. Works with serde derive
}StringWithSeparator eliminates boilerplate parsing code.
Error Handling
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
use serde_with::rust::StringWithSeparator;
use serde_with::formats::CommaSeparator;
#[serde_as]
#[derive(Debug, Deserialize)]
struct NumericIds {
#[serde_as(as = "StringWithSeparator::<CommaSeparator>")]
ids: Vec<i64>,
}
fn error_handling() {
// Valid input
let json = r#"{"ids": "1,2,3"}"#;
let result: Result<NumericIds, _> = serde_json::from_str(json);
assert!(result.is_ok());
// Invalid number in list
let json = r#"{"ids": "1,two,3"}"#;
let result: Result<NumericIds, _> = serde_json::from_str(json);
assert!(result.is_err());
// Error: invalid value: string "two", expected i64
// Wrong type entirely
let json = r#"{"ids": 123}"#; // Not a string
let result: Result<NumericIds, _> = serde_json::from_str(json);
assert!(result.is_err());
// Error: invalid type: integer `123`, expected a string
}Parsing errors are reported with clear messages.
Summary Table
use serde_with::rust::StringWithSeparator;
fn summary() {
// | Feature | Description |
// |------------------------|---------------------------------------|
// | Parse delimited string | "a,b,c" -> ["a", "b", "c"] |
// | Join collection | ["a", "b", "c"] -> "a,b,c" |
// | Type conversion | Works with any Deserialize type |
// | Separator config | Comma, space, semicolon, custom |
// | Optional support | Option<StringWithSeparator<...>> |
// | Empty handling | "" -> [] |
// | Whitespace | Trimmed by default |
}StringWithSeparator handles bidirectional delimited string conversion.
Synthesis
Quick reference:
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
use serde_with::rust::StringWithSeparator;
use serde_with::formats::CommaSeparator;
#[serde_as]
#[derive(Debug, Serialize, Deserialize)]
struct Example {
// Basic usage
#[serde_as(as = "StringWithSeparator::<CommaSeparator>")]
tags: Vec<String>,
// With numbers
#[serde_as(as = "StringWithSeparator::<CommaSeparator>")]
ids: Vec<i64>,
// Optional
#[serde_as(as = "Option<StringWithSeparator::<CommaSeparator>>")]
optional: Option<Vec<String>>,
}
// Deserialize: {"tags":"a,b,c","ids":"1,2,3","optional":"x,y"}
// Result: tags=["a","b","c"], ids=[1,2,3], optional=Some(["x","y"])
// Serialize: tags=["a","b"], ids=[1,2], optional=None
// Result: {"tags":"a,b","ids":"1,2"}When to use StringWithSeparator:
// Use StringWithSeparator when:
// - API returns comma-separated values
// - Configuration uses delimited strings
// - Database stores arrays as delimited strings
// - URL query parameters contain lists
// - CSV-like fields in JSON/TOML/YAML
// Avoid when:
// - Values contain the separator character
// - You need proper CSV handling (quoting, escaping)
// - Data is already structured as JSON arrays
// - Separator needs to be escapedKey insight: StringWithSeparator solves a common impedance mismatch between APIs/configuration formats that use delimited strings and Rust's preference for typed collections. Instead of writing repetitive parsing codeāsplit on comma, trim whitespace, parse each elementāStringWithSeparator integrates directly into serde's derive macros. The serde_as attribute enables this by hooking into serde's (de)serialization pipeline: during deserialization, the string is split by the configured separator (comma by default, but space, semicolon, colon, or custom separators are supported), each element is trimmed and parsed into the target type, and the collection is returned. During serialization, the reverse happens: each element is stringified and joined with the separator. This works with any type implementing Deserialize/Serialize, not just stringsāintegers, booleans, and even complex types can be parsed from delimited strings. The Option<StringWithSeparator<...>> variant handles missing/null fields gracefully, returning None instead of an empty vector. For empty strings, StringWithSeparator returns an empty vector, making it safe for optional fields that might be present but empty.
