mapper is a small Go mapping library inspired by Java MapStruct, but without
code generation. It keeps call sites generic and type-oriented while using
cached, lightweight reflection internally.
go get github.com/arcgolabs/mappertype User struct {
ID int
Name string
}
type UserDTO struct {
ID int
Name string
}
dto, err := mapper.Map[UserDTO](User{ID: 1, Name: "Ada"})The generic helpers keep the destination type visible at the call site:
dto, err := mapper.Map[UserDTO](user)
err := mapper.MapInto(&dto, user)
dtos, err := mapper.MapSlice[UserDTO](users)
dtoByID, err := mapper.MapMap[UserDTO](usersByID)The repository uses a dedicated example/ directory for runnable usage scenarios.
See examples index.
go run ./example/basic
go run ./example/field-mapping
go run ./example/collections
go run ./example/converters
go run ./example/hooks
go run ./example/validation
go run ./example/instances
go run ./example/patch-update
go run ./example/dynamic-inputMapSlice and MapMap infer the source type from the argument, so only the
destination element/value type needs to be written.
Fields are matched by normalized names. Case, underscores, hyphens, spaces, and
dots are ignored, so UserID, user_id, and user-id all match.
Use mapper tags on destination fields when names differ:
type UserDTO struct {
UserID int `mapper:"id"`
Label string `mapper:"name"`
Skip string `mapper:"-"`
}Nested source paths are supported:
type UserDTO struct {
Name string `mapper:"profile.name"`
}Change the tag name when integrating with an existing model:
m := mapper.New(mapper.WithTagName("map"))Use fallback tags when models already carry tags such as json or yaml:
dto, err := mapper.Map[UserDTO](input, mapper.WithFallbackTags("json", "yaml"))Destination mapper tags can also declare required fields and simple defaults:
type UserDTO struct {
Name string `mapper:",required"`
Role string `mapper:",default=user"`
}map[string]any sources can be mapped into structs. This is useful for decoded
configuration, JSON-like data, and private protocol payloads that first land in
a dynamic map:
dto, err := mapper.Map[UserDTO](
map[string]any{"id": 7, "name": "Ada"},
mapper.WithFallbackTags("json"),
)Converters run before built-in assignment and conversion. Use them for business rules such as IDs, timestamps, enums, and formatting.
dto, err := mapper.Map[EventDTO](
event,
mapper.Converter(func(v time.Time) string {
return v.Format(time.RFC3339)
}),
)Error-returning converters are supported:
mapper.ConverterE(func(v string) (UserID, error) {
return ParseUserID(v)
})Register converters on a Mapper instance to reuse them:
m := mapper.New()
_ = m.Register(func(v CustomID) string {
return fmt.Sprintf("U-%d", v)
})
var dto UserDTO
err := m.MapInto(&dto, user)Use hooks for small pieces of mapping logic that should stay handwritten. Hooks run only around the top-level mapping call and match the exact source type plus destination pointer type.
m := mapper.New(
mapper.AfterMap(func(src User, dst *UserDTO) {
dst.FullName = src.FirstName + " " + src.LastName
}),
)Use BeforeMapE or AfterMapE when the hook can fail:
m := mapper.New(
mapper.BeforeMapE(func(src User, dst *UserDTO) error {
if src.ID == 0 {
return errors.New("missing user id")
}
return nil
}),
)mapper can validate the mapped destination through any type that implements:
type ValidationEngine interface {
Struct(any) error
}This is intentionally small so you can plug in standard validator implementations or custom ones with your own rules:
import "github.com/go-playground/validator/v10"
validate := validator.New()
dto, err := mapper.Map[UserDTO](source, mapper.WithValidator(validate))You can also store the validator on a reusable Mapper instance:
m := mapper.New(
mapper.WithTagName("json"),
mapper.WithValidator(validate),
)
_ = m.MapInto(&dto, source)For small custom validators, use ValidationFunc:
err := mapper.MapInto(&dto, source, mapper.WithValidator(mapper.ValidationFunc(func(v any) error {
return nil
})))By default, unmatched destination fields are left unchanged. Use strict mode to turn those into errors.
dto, err := mapper.Map[UserDTO](user, mapper.Strict())MapInto can be used for patch/update workflows. IgnoreNil and IgnoreZero
leave the existing destination value untouched for nil or zero source values:
err := mapper.MapInto(&entity, patch, mapper.IgnoreNil(), mapper.IgnoreZero())Field-level mapping failures wrap MappingError, which carries the field path
and source/destination types. Validation failures wrap ValidationError.
var mappingErr *mapper.MappingError
if errors.As(err, &mappingErr) {
fmt.Println(mappingErr.Path)
}This repository includes a Taskfile.yml for reproducible local workflows:
# Quality checks
task preflight
# Run all examples
task examples
# Create release tag
task release VERSION=v0.1.0
task release VERSION=v0.1.0 PUSH=trueMapping plans are cached with github.com/hashicorp/golang-lru/v2. The default
cache size is 1024 type pairs.
m := mapper.New(mapper.WithPlanCacheSize(4096))The implementation also uses github.com/arcgolabs/collectionx submodules for
collection helpers and keeps converter/hook registries as copy-on-write
snapshots for lock-free reads during mapping.
The plan cache stays on github.com/hashicorp/golang-lru/v2 for bounded LRU
eviction.
- Destination fields are the mapping target; source-only fields are ignored.
- Exported fields only are mapped.
mapper:"-"skips a destination field.mapper:",required"requires a matching source field.mapper:",default=value"fills missing or zero source values.- Converters take precedence over built-in assignment and conversion.
- Hooks run for the top-level call only, not for nested fields or collection items.
- Nil source pointers, maps, and slices map to zero values.
IgnoreNilandIgnoreZeropreserve destination values in patch-style calls.- Whole struct, slice, and map conversion is avoided so nested mapping and converters can run field-by-field.
MapIntopreserves unmatched destination fields unless strict mode is enabled.