or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

docs

basic-operations.mdcustom-serialization.mdindex.mdnode-manipulation.mdstreaming-operations.md
tile.json

custom-serialization.mddocs/

Custom Type Serialization

Interfaces for implementing custom marshaling and unmarshaling behavior for user-defined types. Implement these interfaces to control how your types are represented in YAML.

Marshaler Interface

The Marshaler interface may be implemented by types to customize their behavior when being marshaled into a YAML document.

type Marshaler interface {
    MarshalYAML() (interface{}, error)
}

Method

MarshalYAML

Returns a value that will be marshaled in place of the original value.

MarshalYAML() (interface{}, error)
Returns
  • interface{} - The value to marshal instead of the original type
  • error - Error if marshaling fails, nil otherwise
Behavior
  • The returned value is marshaled in place of the original value implementing Marshaler
  • If an error is returned, the marshaling procedure stops and returns with the provided error
  • The returned value can be any type supported by yaml.Marshal

Usage Example

Custom timestamp formatting:

package main

import (
    "fmt"
    "gopkg.in/yaml.v3"
    "time"
)

type CustomTime struct {
    time.Time
}

func (ct CustomTime) MarshalYAML() (interface{}, error) {
    // Return custom formatted string
    return ct.Format("2006-01-02 15:04:05"), nil
}

type Event struct {
    Name      string
    Timestamp CustomTime
}

func main() {
    event := Event{
        Name:      "UserLogin",
        Timestamp: CustomTime{time.Now()},
    }

    data, err := yaml.Marshal(&event)
    if err != nil {
        panic(err)
    }

    fmt.Printf("%s", data)
    // Output:
    // name: UserLogin
    // timestamp: 2024-01-15 10:30:45
}

Advanced Example: Marshaling to Node

Return a yaml.Node for fine-grained control:

type CustomMap map[string]string

func (cm CustomMap) MarshalYAML() (interface{}, error) {
    // Create a mapping node with flow style
    node := &yaml.Node{
        Kind:  yaml.MappingNode,
        Style: yaml.FlowStyle,
    }

    for key, value := range cm {
        node.Content = append(node.Content,
            &yaml.Node{Kind: yaml.ScalarNode, Value: key},
            &yaml.Node{Kind: yaml.ScalarNode, Value: value},
        )
    }

    return node, nil
}

type Config struct {
    Name     string
    Settings CustomMap
}

func main() {
    config := Config{
        Name: "MyApp",
        Settings: CustomMap{
            "debug":   "true",
            "timeout": "30",
        },
    }

    data, _ := yaml.Marshal(&config)
    fmt.Printf("%s", data)
    // Output:
    // name: MyApp
    // settings: {debug: true, timeout: "30"}
}

Sensitive Data Example

Redact sensitive information during marshaling:

type Password string

func (p Password) MarshalYAML() (interface{}, error) {
    if p == "" {
        return nil, nil
    }
    return "********", nil
}

type Database struct {
    Host     string
    Port     int
    Username string
    Password Password
}

func main() {
    db := Database{
        Host:     "localhost",
        Port:     5432,
        Username: "admin",
        Password: "secret123",
    }

    data, _ := yaml.Marshal(&db)
    fmt.Printf("%s", data)
    // Output:
    // host: localhost
    // port: 5432
    // username: admin
    // password: '********'
}

Unmarshaler Interface

The Unmarshaler interface may be implemented by types to customize their behavior when being unmarshaled from a YAML document.

type Unmarshaler interface {
    UnmarshalYAML(value *Node) error
}

Method

UnmarshalYAML

Custom unmarshaling logic for the type.

UnmarshalYAML(value *Node) error
Parameters
  • value (*Node) - The YAML node to unmarshal from
Returns
  • error - Error if unmarshaling fails, nil otherwise
Behavior
  • Called when unmarshaling YAML data into the type
  • Receives a *yaml.Node representing the YAML content
  • Responsible for populating the receiver's fields from the node

Usage Example

Flexible string or object unmarshaling:

package main

import (
    "fmt"
    "gopkg.in/yaml.v3"
)

type Duration struct {
    Seconds int
}

func (d *Duration) UnmarshalYAML(value *yaml.Node) error {
    // Try to unmarshal as integer (seconds)
    var seconds int
    if err := value.Decode(&seconds); err == nil {
        d.Seconds = seconds
        return nil
    }

    // Try to unmarshal as string (e.g., "30s", "5m")
    var str string
    if err := value.Decode(&str); err != nil {
        return err
    }

    // Parse string format
    if len(str) < 2 {
        return fmt.Errorf("invalid duration: %s", str)
    }

    var multiplier int
    switch str[len(str)-1] {
    case 's':
        multiplier = 1
    case 'm':
        multiplier = 60
    case 'h':
        multiplier = 3600
    default:
        return fmt.Errorf("unknown duration unit: %c", str[len(str)-1])
    }

    var value int
    fmt.Sscanf(str[:len(str)-1], "%d", &value)
    d.Seconds = value * multiplier
    return nil
}

type Config struct {
    Timeout Duration
}

func main() {
    // Test with integer
    data1 := []byte("timeout: 30")
    var c1 Config
    yaml.Unmarshal(data1, &c1)
    fmt.Printf("Timeout: %d seconds\n", c1.Timeout.Seconds) // 30 seconds

    // Test with string
    data2 := []byte("timeout: 5m")
    var c2 Config
    yaml.Unmarshal(data2, &c2)
    fmt.Printf("Timeout: %d seconds\n", c2.Timeout.Seconds) // 300 seconds
}

Validation Example

Validate data during unmarshaling:

type Port int

func (p *Port) UnmarshalYAML(value *yaml.Node) error {
    var port int
    if err := value.Decode(&port); err != nil {
        return err
    }

    if port < 1 || port > 65535 {
        return fmt.Errorf("invalid port number: %d (must be 1-65535)", port)
    }

    *p = Port(port)
    return nil
}

type Server struct {
    Host string
    Port Port
}

func main() {
    data := []byte("host: localhost\nport: 99999")
    var server Server
    err := yaml.Unmarshal(data, &server)
    if err != nil {
        fmt.Println(err) // invalid port number: 99999 (must be 1-65535)
    }
}

Multi-Format Support Example

Support multiple input formats:

type Color struct {
    R, G, B uint8
}

func (c *Color) UnmarshalYAML(value *yaml.Node) error {
    // Try hex string format (e.g., "#FF0000")
    var hex string
    if err := value.Decode(&hex); err == nil {
        if len(hex) == 7 && hex[0] == '#' {
            fmt.Sscanf(hex, "#%02x%02x%02x", &c.R, &c.G, &c.B)
            return nil
        }
    }

    // Try object format (e.g., {r: 255, g: 0, b: 0})
    var rgb struct {
        R uint8 `yaml:"r"`
        G uint8 `yaml:"g"`
        B uint8 `yaml:"b"`
    }
    if err := value.Decode(&rgb); err == nil {
        c.R, c.G, c.B = rgb.R, rgb.G, rgb.B
        return nil
    }

    return fmt.Errorf("invalid color format")
}

type Theme struct {
    Background Color
    Foreground Color
}

func main() {
    // Hex format
    data1 := []byte(`
background: "#FF0000"
foreground: "#0000FF"
`)
    var t1 Theme
    yaml.Unmarshal(data1, &t1)
    fmt.Printf("BG: RGB(%d,%d,%d)\n", t1.Background.R, t1.Background.G, t1.Background.B)

    // Object format
    data2 := []byte(`
background:
  r: 255
  g: 0
  b: 0
foreground:
  r: 0
  g: 0
  b: 255
`)
    var t2 Theme
    yaml.Unmarshal(data2, &t2)
    fmt.Printf("BG: RGB(%d,%d,%d)\n", t2.Background.R, t2.Background.G, t2.Background.B)
}

IsZeroer Interface

The IsZeroer interface is used to determine whether a value should be omitted when marshaling with the omitempty flag.

type IsZeroer interface {
    IsZero() bool
}

Method

IsZero

Returns whether the value should be considered zero (empty).

IsZero() bool
Returns
  • bool - True if the value should be omitted with omitempty, false otherwise
Behavior
  • Called during marshaling when a field has the omitempty tag
  • If the method returns true, the field is omitted from the output
  • The standard library's time.Time implements this interface

Usage Example

Custom zero-value detection:

package main

import (
    "fmt"
    "gopkg.in/yaml.v3"
)

type OptionalString struct {
    Value string
    Set   bool // Track whether value was explicitly set
}

func (s OptionalString) IsZero() bool {
    return !s.Set
}

func (s *OptionalString) UnmarshalYAML(value *yaml.Node) error {
    if err := value.Decode(&s.Value); err != nil {
        return err
    }
    s.Set = true
    return nil
}

func (s OptionalString) MarshalYAML() (interface{}, error) {
    if !s.Set {
        return nil, nil
    }
    return s.Value, nil
}

type Config struct {
    Name        string         `yaml:"name"`
    Description OptionalString `yaml:"description,omitempty"`
    Author      OptionalString `yaml:"author,omitempty"`
}

func main() {
    // With empty string explicitly set
    c1 := Config{
        Name:        "MyApp",
        Description: OptionalString{Value: "", Set: true},
    }

    data1, _ := yaml.Marshal(&c1)
    fmt.Printf("%s", data1)
    // Output:
    // name: MyApp
    // description: ""

    // With value not set
    c2 := Config{
        Name:        "MyApp",
        Description: OptionalString{Value: "", Set: false},
    }

    data2, _ := yaml.Marshal(&c2)
    fmt.Printf("%s", data2)
    // Output:
    // name: MyApp
}

Pointer vs Empty Value Example

Distinguish between nil pointer and empty value:

type Database struct {
    Host string
    Port int
}

func (db Database) IsZero() bool {
    // Consider zero only if both host and port are unset
    return db.Host == "" && db.Port == 0
}

type Config struct {
    Database Database `yaml:"database,omitempty"`
}

func main() {
    // Empty database - will be omitted
    c1 := Config{}
    data1, _ := yaml.Marshal(&c1)
    fmt.Printf("%s", data1)
    // Output: {}

    // Database with only port set - will NOT be omitted
    c2 := Config{
        Database: Database{Port: 5432},
    }
    data2, _ := yaml.Marshal(&c2)
    fmt.Printf("%s", data2)
    // Output:
    // database:
    //   host: ""
    //   port: 5432
}

Combining Interfaces

You can implement multiple interfaces together:

type SmartValue struct {
    value string
}

func (sv SmartValue) MarshalYAML() (interface{}, error) {
    // Custom marshaling
    return strings.ToUpper(sv.value), nil
}

func (sv *SmartValue) UnmarshalYAML(value *yaml.Node) error {
    // Custom unmarshaling
    var str string
    if err := value.Decode(&str); err != nil {
        return err
    }
    sv.value = strings.ToLower(str)
    return nil
}

func (sv SmartValue) IsZero() bool {
    // Custom zero detection
    return sv.value == ""
}

type Config struct {
    Name SmartValue `yaml:"name,omitempty"`
}

func main() {
    // Unmarshal converts to lowercase
    data := []byte("name: HELLO")
    var c Config
    yaml.Unmarshal(data, &c)
    fmt.Printf("Internal: %s\n", c.Name.value) // "hello"

    // Marshal converts to uppercase
    output, _ := yaml.Marshal(&c)
    fmt.Printf("%s", output) // "name: HELLO"
}

Error Handling

Both MarshalYAML and UnmarshalYAML can return errors:

func (ct CustomType) MarshalYAML() (interface{}, error) {
    if !ct.IsValid() {
        return nil, fmt.Errorf("cannot marshal invalid CustomType")
    }
    return ct.value, nil
}

func (ct *CustomType) UnmarshalYAML(value *yaml.Node) error {
    var data string
    if err := value.Decode(&data); err != nil {
        return fmt.Errorf("failed to decode CustomType: %w", err)
    }

    if !validateData(data) {
        return fmt.Errorf("invalid data for CustomType: %s", data)
    }

    ct.value = data
    return nil
}

Best Practices

  1. Implement both interfaces together: If you implement Marshaler, consider implementing Unmarshaler for symmetry
  2. Use pointer receivers for Unmarshaler: Use func (t *Type) UnmarshalYAML to modify the receiver
  3. Validate in UnmarshalYAML: Perform validation during unmarshaling to fail fast
  4. Return yaml.Node for complex output: When you need fine-grained control over the YAML structure, return a *yaml.Node from MarshalYAML
  5. Handle errors gracefully: Return descriptive errors that help users understand what went wrong
  6. Document behavior: Clearly document what formats your custom marshaler/unmarshaler supports

Notes

  • For basic type handling without custom logic, struct tags are usually sufficient (see Basic YAML Operations)
  • For working directly with YAML AST, see Node Manipulation
  • The IsZeroer interface is particularly useful with time.Time and other types where the zero value is meaningful
  • Custom marshalers can return yaml.Node for maximum control over the output structure