K
.agents
feat!: simplify message format contracts
.claude
chore(config): refresh agent wiring and repo docs
.github
feat!: tighten message format contracts
.reference
chore(deps): update release submodules
docs
feat!: simplify message format contracts
examples
feat!: simplify message format contracts
internal
feat!: harden message format contracts
mf1
fix: support go-intl v0.2.17
pkg
fix: support go-intl v0.2.17
SPECS
feat!: harden message format contracts
tests
feat!: harden message format contracts
.editorconfig
chore(config): update golangci-lint to v2.9.0 and standardize configs
.gitattributes
chore: add project configuration files for development consistency
.gitignore
chore(deps): upgrade go-intl to v0.2.8
.gitleaks.toml
chore(deps): upgrade golang.org/x/text to v0.35.0; fix staticcheck lint warnings
.gitmodules
chore: initialize shared skills submodule and symlink
.golangci.version
chore(release): prepare v0.5.3
.golangci.yml
feat!: tighten message format contracts
.yamllint.yml
chore(deps): upgrade golang.org/x/text to v0.35.0; fix staticcheck lint warnings
AGENTS.md
refactor(resolve): move getFirstLocale to functions package
CLAUDE.md
feat!: harden message format contracts
CONTRIBUTING.md
feat!: tighten message format contracts
exports.go
feat!: simplify message format contracts
go.mod
fix: support go-intl v0.2.17
go.sum
fix: support go-intl v0.2.17
lefthook.yml
chore(config): remove markdownlint checks
LICENSE
chore(config): update golangci-lint to v2.9.0 and standardize configs
messageformat_test.go
feat!: harden message format contracts
messageformat.go
feat!: simplify message format contracts
options_test.go
feat!: simplify message format contracts
options.go
feat!: simplify message format contracts
README.md
feat!: harden message format contracts
Taskfile.yml
feat!: tighten message format contracts
test_helpers_test.go
feat!: simplify message format contracts
TESTING.md
feat!: harden message format contracts

MessageFormat Go

A Go implementation of Unicode MessageFormat 2.0 for parsing, validating, and formatting localized messages

Features

  • MessageFormat 2.0: Parse, validate, select, and format messages with the Unicode MessageFormat 2.0 model.
  • Established API surface: Use stable constructor concepts, option names, formatting methods, and runtime defaults across MessageFormat 2 implementations.
  • Locale-aware functions: Format numbers, dates, currencies, percentages, offsets, strings, and units through github.com/agentable/go-intl.
  • Custom formatters: Register application functions with WithFunction or WithFunctions.
  • Structured rendering: Use FormatToParts for rich text, markup-aware rendering, and post-processing.
  • Safe data-model tooling: Construct and serialize the public model with explicit errors and deterministic source output.
  • Migration path: Keep existing ICU MessageFormat v1 code on the supported github.com/kaptinlin/messageformat-go/mf1 module.
  • Pinned corpus verification: Reproduce behavior against the checked-in Unicode MessageFormat Working Group corpus with task test-official or task test-v2.

Installation

go get github.com/kaptinlin/messageformat-go

Requires Go 1.26.5+.

For ICU MessageFormat v1 compatibility, install its independent module:

go get github.com/kaptinlin/messageformat-go/mf1@latest

Then import:

import "github.com/kaptinlin/messageformat-go/mf1"

Quick Start

package main

import (
	"fmt"
	"log"

	"github.com/kaptinlin/messageformat-go"
)

func main() {
	mf, err := messageformat.Parse([]string{"en"}, "Hello, {$name}!")
	if err != nil {
		log.Fatal(err)
	}

	out, err := mf.Format(map[string]any{"name": "World"})
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(out) // Hello, World!
}

Core API

APIPurpose
Parse(locales, source, options...)Parse source text into a formatter
Compile(locales, message, options...)Build a formatter from a parsed data model
(*MessageFormat).Format(values)Format to a string and return runtime diagnostics
(*MessageFormat).FormatToParts(values)Format to structured parts and return runtime diagnostics
datamodel.ParseMessage(source)Parse source into the public data model
datamodel.StringifyMessage(message)Convert the data model back to source or return an error
datamodel.ValidateMessage(message, onError)Validate a parsed message

Full API details are available on pkg.go.dev and in docs/api-reference.md. Import github.com/kaptinlin/messageformat-go/pkg/datamodel for direct model construction, validation, visitation, and type guards.

Common Usage

Select Messages

Declare selectors before .match messages:

mf, err := messageformat.Parse([]string{"en"}, `
.input {$count :number}
.match $count
0   {{No items}}
one {{One item}}
*   {{{$count} items}}
`)
if err != nil {
	log.Fatal(err)
}

out, err := mf.Format(map[string]any{"count": 3})
if err != nil {
	log.Fatal(err)
}

fmt.Println(out)

Built-In Formatting

Use MessageFormat functions directly in source text:

mf, err := messageformat.Parse(
	[]string{"en"},
	"Total: {$amount :currency currency=USD}",
)
if err != nil {
	log.Fatal(err)
}

out, err := mf.Format(map[string]any{"amount": 29.99})
if err != nil {
	log.Fatal(err)
}

fmt.Println(out)

Stable default functions include :number, :integer, :string, :offset, :currency, and :percent. Draft date/time/unit functions (:date, :datetime, :time, :unit) are available only when supplied explicitly with WithFunctions(functions.DraftFunctionMap()); :math is an extension function and must be supplied explicitly with WithFunction.

Structured Parts

Use FormatToParts when a UI needs structured output instead of one string. Format follows the documented string conversion path; FormatToParts keeps the resolved part values for rich rendering. Package-owned expression parts retain their concrete Go types when universal u:id or u:dir metadata is applied, so type-specific accessors remain available.

parts, err := mf.FormatToParts(map[string]any{"amount": 29.99})
if err != nil {
	log.Fatal(err)
}

for _, part := range parts {
	fmt.Printf("%s: %v\n", part.Type(), part.Value())
}

Custom Functions

Custom functions receive locale context, resolved options, and the operand value:

func uppercase(
	ctx messageformat.MessageFunctionContext,
	options functions.Options,
	operand any,
) messagevalue.MessageValue {
	locale := "en"
	if locales := ctx.Locales(); len(locales) > 0 {
		locale = locales[0]
	}

	return messagevalue.NewStringValue(
		strings.ToUpper(fmt.Sprint(operand)),
		locale,
		ctx.Source(),
	)
}

mf, err := messageformat.Parse(
	[]string{"en"},
	"Hello, {$name :uppercase}!",
	messageformat.WithFunction("uppercase", uppercase),
)

See docs/custom-functions.md and examples/custom-functions for complete examples.

Use ICU MessageFormat 1

The independent mf1 module keeps ICU MessageFormat v1 patterns on a typed Go boundary. Construct from one locale, compile once, and reuse the returned message:

messageFormat, err := mf1.New("en", nil)
if err != nil {
	log.Fatal(err)
}

compiled, err := messageFormat.Compile("Hello, {name}!")
if err != nil {
	log.Fatal(err)
}

out, err := compiled.Format(map[string]any{"name": "World"})
if err != nil {
	log.Fatal(err)
}

fmt.Println(out) // Hello, World!

Use mf1.NewWithPlural with a mf1.PluralProfile for caller-supplied plural behavior. Malformed locale tags return an error; syntactically valid locales without plural data use the module's stable fallback locale. Plural operands preserve exact Go integers and base-10 integer strings through exact matching, offset calculation, category selection, and # formatting. Selector failures are returned by both output projections rather than silently choosing other.

Configuration

Use functional options for focused constructor changes:

OptionPurposeDefault
WithBidiIsolation(strategy)Control Unicode bidi isolation markersBidiDefault
WithDir(direction)Set message base directionLocale-derived
WithLocaleMatcher(matcher)Select locale matching behaviorLocaleBestFit
WithFunction(name, fn)Register one custom functionBuilt-ins only
WithFunctions(funcs)Register multiple custom functionsBuilt-ins only

Example:

mf, err := messageformat.Parse(
	[]string{"ar"},
	"مرحبا {$name}!",
	messageformat.WithDir(messageformat.DirRTL),
)

BidiIsolation, Direction, and LocaleMatcher use closed typed vocabularies. Parse and Compile return an error matching ErrInvalidOption when a value is outside its exported constants.

Use messageformat.Options(...) when a struct is more convenient:

mf, err := messageformat.Parse(
	[]string{"en"},
	"Hello, {$name}!",
	messageformat.Options(messageformat.MessageFormatOptions{
		BidiIsolation: messageformat.BidiDefault,
		LocaleMatcher: messageformat.LocaleBestFit,
	}),
)

Conformance

The tests/messageformat-wg gitlink pins the official Unicode MessageFormat Working Group corpus used by this repository. A passing run describes that exact pin.

task test-official  # Official MessageFormat 2.0 suite only
task test-v2        # Package tests plus official suite, with race detection

Project design contracts live in SPECS/.

Documentation

GuideUse it for
docs/getting-started.mdInstallation and first steps
docs/message-syntax.mdMessageFormat 2.0 syntax
docs/formatting-functions.mdBuilt-in formatter behavior
docs/custom-functions.mdCustom formatter authoring
docs/error-handling.mdSyntax, resolution, and selection errors
docs/api-reference.mdPublic API reference
SPECS/Design contracts and architecture boundaries

Runnable examples live in examples/.

Development

task submodules      # Initialize official test suite submodule
task test            # Run all tests with race detection
task test-v2         # Run package tests and the official suite
task lint            # Check tidy state and lint both modules
task verify          # Run read-only vet, lint, test, and vuln checks for both modules
task examples        # Run example programs

Contributing

Contributions are welcome. Start with CONTRIBUTING.md, then run task verify before opening a pull request.

License

This project is licensed under the MIT License - see the LICENSE file for details.