A Go tool for automatically generating mapping code for similar types.
Morph is a CLI tool, but can also be used as a library, to be integrated into other applications as part of more complex code-generation pipelines.
Morph generates functions like this:
func MapFromRecipeToToRecipe(source *from.Recipe) to.Recipe {
if source == nil {
return to.Recipe{}
}
var target to.Recipe
target.ID = to.RecipeID(source.RecipeId)
target.Name = source.Name
target.Servings = int(source.Servings)
return target
}Morph can map structs and enums, including fields containing basic types, pointers, slices, arrays, maps, nested structs, and concrete generic containers, using direct assignment, safe or configured type conversions, generated nested mappers, and user-provided callables.
Using certain libraries and tools can mean you to end up with what are essentially duplicated types.
For example, the ProtoBuf compiler doesn't generate idiomatic Go code, so you may want to represent
the same types with idiomatic Go code (e.g. using time.Time, with correct initialism in field
names, so on), or maybe you have a database library which uses code-gen.
Morph exists to attempt to alleviate the burden of writing boring, error-prone, time-consuming manual mapping code for these types.
Install Morph using the Go toolchain:
$ go install github.com/seeruk/morph/cmd/morph@latestMorph requires a configuration file to get started. You can find more about that in the Configuration Overview section below.
Once you have a valid configuration file, you can run Morph.
If you have a morph.yaml in the same folder:
$ morphThis is equivalent to the explicit generate command:
$ morph generateIf you want to point Morph at a specific configuration file:
$ morph -config path/to/config.ymlYou can preview what Morph would write without changing files:
$ morph --dry-run
$ morph generate --dry-runMorph plans and generates code based on where the config file is. The configuration file must be within a Go module.
Morph requires a configuration file to function. It does not support taking parameters as flags. A very basic configuration file to map between a few types in a couple of packages could look like this:
# yaml-language-server: $schema=https://raw.githubusercontent.com/seeruk/morph/main/schemas/config.schema.json
packages:
- source: example.com/foodplanner/foodpb
target: example.com/foodplanner/food
types:
- name: Recipe
- name: Ingredient
- source: Difficulty
target: RecipeDifficultyConfiguration allows you to control quite a lot about how mapping works, what is generated, where it gets generated, and what other resources Morph can draw on.
The following sections cover other config sections, and following that are some other common "recipes" for things you might want to be able to do with Morph.
Defaults Hierarchy
Morph configuration is layered, allowing you to specify defaults, and subsequently override them at
more granular levels. Morph aims to be an unopinionated tool with sensible defaults. Top-level
defaults are specified in the defaults section of the configuration.
The order of preference is:
- Property-level config
- Type-level config
- Type preset
- Package-level config
- Package preset
- Top-level default config
- Morph built-in defaults
It's worth noting, configuration on a package, type, or property level does not trickle down to nested mapping functions that Morph generates automatically. If you need Morph to make a customized mapper, it must be specified in the config file, or use top-level defaults.
Conversions
Morph supports generating type conversions between basic types when it's safe to do so. This behaviour can be extended through configuration, allowing unsafe basic conversions, and allowing custom types to be converted if their underlying type supports it.
Conversions are configured at the top-level in configuration:
conversions:
- source: int
targets:
- int64
- uint64
bidirectional: true
- source: StringBasedID
targets:
- stringAs conversions are global configuration, you might find there are scenarios where you want to disable them for certain packages, types, or properties. This can be done at any of these levels like so:
packages:
- source: example.com/source
target: example.com/target
conversions:
enabled: false # Disable for this package pair.
types:
- name: Example
conversions:
enabled: true # Re-enable for this type pair.
struct:
properties:
- name: LegacyID
conversions:
enabled: false # Disable again for this property.Discovery
Morph supports automatically finding and using potentially compatible mapping functions. This functionality is separate from explicitly asking Morph to use callables for mapping, and allows Morph to automatically use functions from explicitly listed packages, like so:
discovery:
packages:
- github.com/example/mappers/datetime
- github.com/example/mappers/numeric
exclusions:
- github.com/example/mappers/numeric.IntToInt64Exclusions can be provided to prevent Morph from using specific functions discovered in these packages, which can be useful if there are many potential functions, and not all of them are actually intended for use as mapping functions.
Callables
Callables are functions or methods that can be explicitly referenced in the config file for Morph to potentially use for mapping, instead of Morph generated the mapping itself. There are 2 main kinds of callables:
Plain callables are simple functions which take a source type and return a target type. These callables can error, and if they do, that errability will propagate up to the parent mapper it's used in, and so on.
func FooToBar(foo Foo) Bar
func FooToBarE(foo Foo) (Bar, error)Morph does also support generic callables, as long as they're used on matching concrete types. For
example. You might have an Optional[T any] and a Nullable[T any], and they might be used on a
source field like Foo Optional[string] to Foo Nullable[string] - this is fine, and works pretty
much the same as above:
func OptionalToNullable[T any](o Optional[T]) Nullable[T]
func OptionalToNullable[T any](o Optional[T]) (Nullable[T], error)There are potential generic cases where Morph cannot use these functions though, for example, if the
type arguments differ on the source and target type (Foo Optional[Bar] to Foo Nullable[Qux]). In
this case, Morph wouldn't be able to map the inner type argument, it has no way to control it. For
these kinds of cases, you can use a combinator callable.
Combinator callables allow you to provide callables to Morph which can be used to handle many generic types. They look like this:
func OptionalToNullable[I, O any](o Optional[I], mapFn func(I) O) Nullable[O]
func OptionalToNullableE[I, O any](o Optional[I], mapFn func(I) (O, error)) (Nullable[O], error)Morph can pass mapping functions it uses, or generates, or can generate inline mapping functions to
pass to these callables. If there are multiple type parameters, Morph expects a mapping function
argument on the callable for each type parameter on the source/target type; for example, for an
Either[L, R any] to Tuple[A, B] conversion, you could have:
func EitherToTuple[L, R, A, B any](
e Either[L, R],
mapLeft func(L) A,
mapRight mapRight func(R) B,
) Tuple[A, B]The mapping functions should look like plain callables, and each mapping function argument may return an error.
The aforementioned discovery is only for auto-discovery of entire packages worth of functions, for other callables to be used by Morph, you must specify them explicitly. Discovery is a nice way to include packages designed specifically for mapping, but you could end up pulling in way more than you want. Also, discovery is not scoped.
Explicitly configuring callables is the solution to both of those issues. Similar to other configuration options, you can configure callables in defaults, presets, on packages, on types, and on specific properties. Configuration looks something like this:
packages:
- source: example.com/foodplanner/foodpb
target: example.com/foodplanner/food
types:
- name: Recipe
callables:
- google.golang.org/protobuf/types/known/timestamppb.Timestamp.AsTime
- google.golang.org/protobuf/types/known/timestamppb.NewIn the above example, since this is specified at the type level, these functions can be used by Morph for any property's value mapping. It will not trickle down to nested mappings.
Scoped callables are prioritized by where they are configured: type callables are tried before type preset callables, then package callables, package preset callables, and finally defaults. Within the same priority, Morph uses callable compatibility rank to choose the best candidate.
Specifying callables in the defaults section will make the callables available to any mapper at
the lowest priority.
Property callables can also receive ordered context source arguments. Context arguments must be exact source fields or zero-argument methods; Morph does not infer or strip accessor prefixes for them.
packages:
- source: example.com/foodplanner/foodpb
target: example.com/foodplanner/food
types:
- name: Recipe
struct:
properties:
- name: Title
callable:
forward:
ref: example.com/foodplanner/food.OmittableFromPresence
args:
- source: MorphHasTitlePresets
Morph allows you to write named collections of default configuration which can be applied at the package or type level. If you have a common pattern you want to use for certain packages, then it means you can drastically cut down on duplicate config. Presets can be defined as so:
presets:
protobuf:
bidirectional: true
callables:
- google.golang.org/protobuf/types/known/timestamppb.Timestamp.AsTime
- google.golang.org/protobuf/types/known/timestamppb.New
enum:
failureMode: error
patterns:
source: "{{ .Type.Pascal }}_{{ .Type.Screaming }}_{{ .Value.Screaming }}"
target: "{{ .Type.Pascal }}{{ .Value.Pascal }}"
mappers:
forward:
name: Map{{ .Target.Type }}FromProto
signature:
accepts: pointer
returns: value
inverse:
name: Map{{ .Target.Type }}ToProto
signature:
accepts: value
returns: pointer
optionality:
onNilSourcePointer: zero
onZeroSourceValue: nil
# And then applied:
packages:
- source: example.com/foopb
target: example.com/foo
preset: protobuf
types:
- name: Bar
# Or at the specific type level
preset: protobufConfiguring Output
By default, Morph generates code into a single mapping package, in a mapping directory next to
the configuration file. You can configure this globally:
defaults:
packages:
output:
strategy: single_package
path: internal/mapping
package: mapping
filename: mapping.morph.goOr, for a specific package mapping:
packages:
- source: example.com/foodplanner/foodpb
target: example.com/foodplanner/food
output:
strategy: target_package
filename: mapping.morph.go
types:
- name: RecipeThere are 3 output strategies:
single_packagewrites generated code to the configuredpathandpackage.source_packagewrites generated code into the source package.target_packagewrites generated code into the target package.
For source_package and target_package, only filename is used. The package name and path come
from the existing package Morph is writing into.
Customizing Mapper Function Names
Morph generates mapper function names from templates. You can configure mapper names in defaults, presets, on packages, or on individual types. If you're generating ProtoBuf mappings, for example, you might want names which make the direction clearer:
packages:
- source: example.com/foodplanner/foodpb
target: example.com/foodplanner/food
mappers:
forward:
name: Map{{ .Target.Type }}FromProto
inverse:
name: Map{{ .Target.Type }}ToProto
bidirectional: true
types:
- name: RecipeWith the above config, Morph would generate names like MapRecipeFromProto and
MapRecipeToProto.
Patterns use Go's text/template library. Input to the template is nameTemplateData found in
naming.go. The package values are the Go package names with the first letter
uppercased, and the signature values are rendered as Value or Pointer.
Customizing Mapper Signatures
Similar to configuring mapper names, you can customize the signature of a mapper, controlling whether the function accepts/returns pointers/values:
packages:
- source: example.com/foodplanner/foodpb
target: example.com/foodplanner/food
types:
- name: Recipe
mappers:
forward:
name: Map{{ .Source.Type }}PointerTo{{ .Target.Type }}
signature:
accepts: pointer
returns: valueOverriding Property / Enum Value Mapping
Morph will try to match logical struct properties by name, including case-insensitive matches. A property is usually backed by a Go field, but can also be backed by getter and setter methods. If property names don't match clearly, you can map them explicitly:
packages:
- source: example.com/foodplanner/foodpb
target: example.com/foodplanner/food
types:
- name: Recipe
struct:
properties:
- source: RecipeId
target: IDYou can also configure exact accessors when Morph should read or write through specific methods:
packages:
- source: example.com/foodplanner/foodpb
target: example.com/foodplanner/food
types:
- name: Recipe
struct:
properties:
- source: EmailAddress
target: Email
accessors:
forward:
read: GetEmailAddress
write: SetEmailEnums work similarly. Morph will try to infer enum mappings by normalizing names, but you can provide explicit value mappings where names don't line up:
packages:
- source: example.com/foodplanner/foodpb
target: example.com/foodplanner/food
types:
- source: Difficulty
target: RecipeDifficulty
enum:
failureMode: error
values:
Difficulty_DIFFICULTY_UNSPECIFIED: RecipeDifficultyUnknownBy default, enum mappers return an error when the source value falls through the generated switch.
Set failureMode: zero to return the target enum's zero value instead, or failureMode: fallback
to return a configured target enum constant. Fallback mode also allows inferred source constants
with no target match; those constants are omitted from the switch and use the fallback at runtime.
Explicit values entries are still validated.
packages:
- source: example.com/foodplanner/foodpb
target: example.com/foodplanner/food
types:
- source: Difficulty
target: RecipeDifficulty
enum:
failureMode: fallback
fallback:
forward: RecipeDifficultyUnknown
inverse: Difficulty_UNSPECIFIEDFor enums with regular generated naming patterns, you can also configure patterns instead of listing every value:
packages:
- source: example.com/foodplanner/foodpb
target: example.com/foodplanner/food
types:
- source: Difficulty
target: RecipeDifficulty
enum:
patterns:
source: "{{ .Type.Pascal }}_{{ .Type.Screaming }}_{{ .Value.Screaming }}"
target: "{{ .Type.Pascal }}{{ .Value.Pascal }}"Patterns use Go's text/template library. Input to the template is enumTemplateData found in
planner_enum.go.
Omitting Properties
Morph reports coverage warnings when a target property cannot be populated from a source property, or vice versa. If a property is intentionally outside the mapping, you can omit it like so:
packages:
- source: example.com/foodplanner/foodpb
target: example.com/foodplanner/food
bidirectional: true
types:
- name: Recipe
struct:
omit:
both:
- Name
source:
- InternalState
target:
- CreatedAt
- UpdatedAtUse both when the same logical property exists on both sides but should not be mapped. Use source
for source-only properties that should be ignored, and target for target-only properties that should
be left unset.
For bidirectional mappings, source and target omissions are inverted automatically. Properties listed
under source are treated as target omissions on the inverse mapper, and properties listed under
target are treated as source omissions on the inverse mapper. Properties listed under both remain
matched omissions in both directions.
Bidirectional Mapping
Many mappings are useful in both directions. You can enable bidirectional mapping in defaults, in a preset, on a package, or on a specific type:
packages:
- source: example.com/foodplanner/foodpb
target: example.com/foodplanner/food
bidirectional: true
types:
- name: Recipe
- source: Difficulty
target: RecipeDifficultyThis will generate both foodpb -> food and food -> foodpb mappings. Struct property mappings and
enum value mappings are inverted automatically for the inverse mapper. Enum fallback values are
configured directionally because each generated mapper returns a different target enum type.
If only one type should be bidirectional, configure it at the type level:
packages:
- source: example.com/foodplanner/foodpb
target: example.com/foodplanner/food
types:
- name: Recipe
bidirectional: true- Morph only generates top-level mappers for struct-to-struct or enum-to-enum mappings and does not support generating mapping functions for other types (e.g. basic types, slices, maps, so on).
- Morph does not load test packages, so cannot create mappings for types in test files.
- Morph does not support embedded fields.
- Morph only recognizes the standard, built-in
errortype for discovery, not custom aliases or wrappers. - Morph does not support creating mappers explicitly for generic types. See docs/decisions/01-high-order-explicit-roots.md for the rationale.
- Morph assumes at least one package referenced in the spec is the main module. If this is not the case, Morph will not be able to figure out the workspace and planning will fail.
- If using
single_packageoutput, package name detection includes files that have build constraints, which could mean either the package name is incorrect, or that an error is returned when it shouldn't be.
- Assignment of literal / constant values for unmapped fields (i.e. while mapping set field x to y)
- CLI improvements:
morph plan/morph explain, some sort of human-readable and/or machine-readable plan viewmorph init, maybe point it at packages or something? Or maybe a different command which creates or updates config to include pairs of types found?morph scanor something?
- Package-local helpers could support cross-package mappings involving unexported fields.
- Built-in helpers which can be used for discovery en masse
MIT