Custom Converters and Semantic Parsing

So far, we have treated directive parameters as simple values: numbers, strings, booleans.

In this guide, we make an important shift: parameters are not values — they are domain literals.

Tagex embraces this by letting each directive define how its own parameters are parsed.

The Problem: One Type, Many Meanings

Consider the following struct:


type Limits struct {
    MaxSize   int64 `units:"bytes, max=10MiB"`
    TimeoutMs int64 `units:"duration, value=250ms"`
}

Both parameters:

  • Start as raw strings ("10MiB", "250ms")
  • End up as int64

But they clearly do not share a grammar.

Parsing these values with a shared, type-based converter would require:

  • Guessing intent
  • Branching on directive names
  • Hidden coupling between unrelated semantics

Tagex avoids this entirely by making parsing a responsibility of the directive itself.

Directive-Owned Parameter Parsing

A directive may implement custom parameter parsing by providing a ConvertParam method.

This method receives:

  • The directive parameter field
  • The raw string from the tag
  • The destination value to populate

Nothing is inferred. Nothing is shared. Meaning stays local.

Example: Parsing Byte Sizes


type BytesDirective struct {
    Max int64 `param:"max"`
}

func (d *BytesDirective) Name() string { return "bytes" }
func (d *BytesDirective) Mode() tagex.DirectiveMode { return tagex.EvalMode }

func (d *BytesDirective) ConvertParam(
    field reflect.StructField,
    fieldValue reflect.Value,
    raw string,
) error {
    if field.Name != "Max" {
        return nil
    }

    n, err := parseBytes(raw) // "10MiB", "512KiB"
    if err != nil {
        return tagex.NewConversionError(field, raw, "int64")
    }

    fieldValue.SetInt(n)
    return nil
}

The grammar (MiB, KiB, etc.) is owned entirely by the bytes directive.

Example: Parsing Durations


type DurationDirective struct {
    Value int64 `param:"value"`
}

func (d *DurationDirective) Name() string { return "duration" }
func (d *DurationDirective) Mode() tagex.DirectiveMode { return tagex.EvalMode }

func (d *DurationDirective) ConvertParam(
    field reflect.StructField,
    fieldValue reflect.Value,
    raw string,
) error {
    if field.Name != "Value" {
        return nil
    }

    dur, err := time.ParseDuration(raw)
    if err != nil {
        return tagex.NewConversionError(field, raw, "int64")
    }

    fieldValue.SetInt(int64(dur / time.Millisecond))
    return nil
}

This directive parses a completely different grammar, even though it writes to the same Go type.

Why This Design Matters

By making directives responsible for parsing:

  • Each directive defines its own literal language
  • No global or tag-level logic needs to guess intent
  • Multiple directives can coexist safely
  • Documentation and code stay aligned

Parsing becomes part of semantics — not infrastructure.

Default Behavior Still Exists

Directives that do not implement custom parsing automatically fall back to Tagex’s default behavior for primitive types.

You only pay for complexity when you need it.

The Bigger Picture

With directive-owned conversion, Tagex now supports:

  • Human-readable configuration
  • Domain-specific literals
  • Multiple grammars for the same Go type
  • Strict semantic locality

This completes the core model:

  • Tags select semantics
  • Directives define meaning
  • Parsing is part of that meaning

Where to Go Next

At this point, you have seen the full expressive range of Tagex: behavior, capability, success boundaries, validation, and parsing.

The remaining work is composition — building your own directive libraries and letting tags speak your domain’s language.