Agent skill
serde-patterns
Serialization patterns with serde and serde_json
Install this agent skill to your Project
npx add-skill https://github.com/johnlindquist/script-kit-next/tree/main/.opencode/skill/serde-patterns
SKILL.md
serde-patterns
Serde is Rust's framework for serializing and deserializing data structures. Script-kit-gpui uses it extensively for:
- Config files:
~/.scriptkit/kit/config.json,theme.json - IPC protocol: JSON messages between SDK and app
- Script metadata: Typed metadata in script files
- Persistence: Window state, shortcuts, frecency data
Derive Macros
The most common pattern - derive Serialize and Deserialize:
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub hotkey: HotkeyConfig,
pub editor: Option<String>,
}
For deserialization only (common for incoming protocol messages):
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum ExternalCommand {
Run { path: String },
Show { request_id: Option<String> },
}
Common Attributes
Field Renaming
// Rename all fields to camelCase (JavaScript convention)
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BuiltInConfig {
pub clipboard_history: bool, // serializes as "clipboardHistory"
pub app_launcher: bool, // serializes as "appLauncher"
}
// Rename individual field
#[serde(rename = "requestId")]
pub request_id: String,
// Rename for both ser/de or separately
#[serde(rename(serialize = "Foo", deserialize = "foo"))]
Default Values
// Use Default::default() for missing fields
#[serde(default)]
pub tags: Vec<String>,
// Use a custom function for default
#[serde(default = "default_grid_size")]
pub grid_size: u32,
fn default_grid_size() -> u32 {
8
}
// Default for nested structs
#[serde(default)]
pub terminal: TerminalColors, // uses TerminalColors::default()
Skip Serialization
// Skip if None
#[serde(skip_serializing_if = "Option::is_none")]
pub editor: Option<String>,
// Skip if empty vector
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub suggestions: Vec<String>,
// Skip always (computed field)
#[serde(skip)]
pub cached_value: Option<i32>,
// Common pattern: default + skip combo
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actions: Option<Vec<ProtocolAction>>,
Tagged Enums
// Externally tagged (default): {"Run": {"path": "..."}}
#[derive(Deserialize)]
enum Message {
Run { path: String },
}
// Internally tagged: {"type": "run", "path": "..."}
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
enum ExternalCommand {
Run { path: String },
Show { request_id: Option<String> },
}
// Custom tag name with rename
#[serde(tag = "type")]
enum Message {
#[serde(rename = "arg")]
Arg { id: String, placeholder: String },
#[serde(rename = "submit")]
Submit { id: String, value: Option<String> },
}
Flatten
// Merge fields into parent
#[derive(Serialize, Deserialize)]
struct LayoutInfoResult {
request_id: String,
#[serde(flatten)]
info: LayoutInfo, // LayoutInfo fields appear at top level
}
// Capture unknown fields
#[derive(Deserialize)]
struct TypedMetadata {
pub name: Option<String>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
Usage in script-kit-gpui
Config Loading (src/config/types.rs)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Config {
pub hotkey: HotkeyConfig,
#[serde(skip_serializing_if = "Option::is_none")]
pub bun_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "editorFontSize")]
pub editor_font_size: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "builtIns")]
pub built_ins: Option<BuiltInConfig>,
}
IPC Protocol Messages (src/protocol/message.rs)
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum Message {
#[serde(rename = "arg")]
Arg {
id: String,
placeholder: String,
choices: Vec<Choice>,
#[serde(default, skip_serializing_if = "Option::is_none")]
actions: Option<Vec<ProtocolAction>>,
},
#[serde(rename = "div")]
Div {
id: String,
html: String,
#[serde(rename = "containerClasses", skip_serializing_if = "Option::is_none")]
container_classes: Option<String>,
#[serde(rename = "containerPadding", skip_serializing_if = "Option::is_none")]
container_padding: Option<serde_json::Value>,
},
}
Theme Colors with Custom Serialization (src/theme/hex_color.rs)
pub type HexColor = u32;
// Custom module for special serialization
pub mod hex_color_serde {
use super::*;
use serde::de::{self, Visitor};
pub fn serialize<S>(color: &HexColor, serializer: S) -> Result<S::Ok, S::Error>
where S: Serializer {
serializer.serialize_str(&format!("#{:06X}", color))
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<HexColor, D::Error>
where D: Deserializer<'de> {
struct HexColorVisitor;
impl<'de> Visitor<'de> for HexColorVisitor {
type Value = HexColor;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a number, hex string, or rgba()")
}
fn visit_u64<E>(self, value: u64) -> Result<HexColor, E> {
Ok(value as HexColor)
}
fn visit_str<E>(self, value: &str) -> Result<HexColor, E>
where E: de::Error {
parse_color_string(value).map_err(de::Error::custom)
}
}
deserializer.deserialize_any(HexColorVisitor)
}
}
// Usage in structs
#[derive(Serialize, Deserialize)]
pub struct BackgroundColors {
#[serde(with = "hex_color_serde")]
pub main: HexColor, // Accepts: "#1E1E1E", "rgb(30,30,30)", 1973790
}
Script Metadata (src/metadata_parser.rs)
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct TypedMetadata {
pub name: Option<String>,
pub description: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub hidden: bool,
// Capture any extra fields
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
Custom Serialization
Using serialize_with / deserialize_with
#[derive(Serialize, Deserialize)]
pub struct DropShadow {
#[serde(with = "hex_color_serde")]
pub color: HexColor,
}
// For Option<T> with custom serde
pub mod hex_color_option_serde {
pub fn serialize<S>(color: &Option<HexColor>, serializer: S) -> Result<S::Ok, S::Error> {
match color {
Some(c) => serializer.serialize_str(&format!("#{:06X}", c)),
None => serializer.serialize_none(),
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<HexColor>, D::Error> {
// Custom visitor implementation
}
}
Implementing Visitor Pattern
struct HexColorVisitor;
impl<'de> Visitor<'de> for HexColorVisitor {
type Value = HexColor;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a number, hex string (#RRGGBB), or rgba(r, g, b, a)")
}
fn visit_u64<E>(self, value: u64) -> Result<HexColor, E>
where E: de::Error {
Ok(value as HexColor)
}
fn visit_i64<E>(self, value: i64) -> Result<HexColor, E>
where E: de::Error {
Ok(value as HexColor)
}
fn visit_str<E>(self, value: &str) -> Result<HexColor, E>
where E: de::Error {
parse_color_string(value).map_err(de::Error::custom)
}
}
Handling Unknown Fields
Strict Mode (Reject Unknown)
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StrictConfig {
pub name: String,
}
Capture Unknown Fields
#[derive(Deserialize)]
pub struct FlexibleConfig {
pub name: String,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
JSON Patterns
Parsing JSON
// From string
let config: Config = serde_json::from_str(&content)?;
// From reader (file)
let config: Config = serde_json::from_reader(file)?;
// From bytes
let config: Config = serde_json::from_slice(&bytes)?;
Serializing to JSON
// To string
let json = serde_json::to_string(&config)?;
// To pretty string (for config files)
let json = serde_json::to_string_pretty(&config)?;
// To writer
serde_json::to_writer(file, &config)?;
// To Value
let value: serde_json::Value = serde_json::to_value(&config)?;
Working with Dynamic JSON (serde_json::Value)
use serde_json::{json, Value};
// Create JSON dynamically
let value = json!({
"name": "John Doe",
"age": 43,
"phones": ["+44 1234567"]
});
// Access fields
if let Some(name) = value["name"].as_str() {
println!("Name: {}", name);
}
// Modify
if let Some(obj) = value.as_object_mut() {
obj.insert("email".to_string(), json!("john@example.com"));
}
// Convert Value to typed struct
let person: Person = serde_json::from_value(value)?;
The json! Macro
use serde_json::json;
// Build complex JSON with interpolation
let name = "Script Kit";
let version = 1;
let metadata = json!({
"name": name,
"version": version,
"features": ["arg", "div", "editor"],
"config": {
"enabled": true,
"timeout": null
}
});
Anti-patterns
1. Missing #[serde(default)] for Optional Collections
// BAD: Fails if "tags" is missing from JSON
pub tags: Vec<String>,
// GOOD: Uses empty vec if missing
#[serde(default)]
pub tags: Vec<String>,
2. Inconsistent Naming Without rename_all
// BAD: Rust fields don't match JSON camelCase
pub struct Config {
pub editor_font_size: f32, // JSON has "editorFontSize"
}
// GOOD: Automatic conversion
#[serde(rename_all = "camelCase")]
pub struct Config {
pub editor_font_size: f32,
}
3. Not Using skip_serializing_if for Optional Fields
// BAD: Serializes as {"value": null}
pub value: Option<String>,
// GOOD: Omits field when None
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<String>,
4. Forgetting #[serde(default)] with skip_serializing_if
// BAD: Can serialize but not deserialize if field missing
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<String>,
// GOOD: Handles both directions
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<String>,
5. Using String for Enums in JSON
// BAD: Serializes as string, lossy
pub action: String, // "copy" or "paste"
// GOOD: Type-safe enum
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ClipboardAction {
Copy,
Paste,
Read,
}
6. Ignoring Errors in Config Loading
// BAD: Silent failures
let config = serde_json::from_str(&content).unwrap_or_default();
// GOOD: Log errors, use defaults gracefully
match serde_json::from_str::<Config>(&content) {
Ok(config) => config,
Err(e) => {
tracing::warn!(error = %e, "Failed to parse config, using defaults");
Config::default()
}
}
Best Practices
- Always derive both
SerializeandDeserializeunless you only need one direction - Use
#[serde(rename_all = "camelCase")]for JavaScript/TypeScript interop - Combine
defaultwithskip_serializing_iffor optional fields - Use custom modules (
with = "...")for complex types like colors, dates - Prefer typed enums over strings for discriminated unions
- Use
#[serde(flatten)]to capture unknown fields or compose structs - Add
#[serde(default)]toVec<T>fields that might be missing
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
Generate Component Documentation
Based on existing docs styles and specific API implementations, and referencing same name stories, generate comprehensive documentation for the new component.
Generate Component Story
Generate a comprehensive story for a new component for as example.
new-component
How to write a new component of GPUI Component.
troubleshooting
Diagnose and fix common Script Kit issues. Use when the user reports bugs, crashes, missing features, or unexpected behavior in Script Kit GPUI.
script-authoring
Create and manage TypeScript scripts for Script Kit. Use when the user wants to write a new script, edit an existing script, or understand Script Kit's SDK and metadata system.
agents
Create mdflow-backed agent files for Script Kit. Use when the user wants to create AI agents, configure agent backends (Claude, Gemini, Codex), or manage agent metadata.
Didn't find tool you were looking for?