morph

package module
v0.0.0-...-2d256a6 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 14, 2026 License: MIT Imports: 29 Imported by: 0

README

Morph

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.

Why Morph?

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.

Quick Start

Install Morph using the Go toolchain:

$ go install github.com/seeruk/morph/cmd/morph@latest

Morph 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:

$ morph

This is equivalent to the explicit generate command:

$ morph generate

If you want to point Morph at a specific configuration file:

$ morph -config path/to/config.yml

You can preview what Morph would write without changing files:

$ morph --dry-run
$ morph generate --dry-run

Morph plans and generates code based on where the config file is. The configuration file must be within a Go module.

Configuration Overview

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: RecipeDifficulty

Configuration 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
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:

  1. Property-level config
  2. Type-level config
  3. Type preset
  4. Package-level config
  5. Package preset
  6. Top-level default config
  7. 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
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:
  - string

As 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
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.IntToInt64

Exclusions 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
What are 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

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

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.

Configuring Callables

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.New

In 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: MorphHasTitle
Presets
Presets

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: protobuf
Other Common Scenarios
Configuring Output
Configuring 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.go

Or, 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: Recipe

There are 3 output strategies:

  • single_package writes generated code to the configured path and package.
  • source_package writes generated code into the source package.
  • target_package writes 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
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: Recipe

With 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
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: value
Overriding Property / Enum Value Mapping
Overriding 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: ID

You 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: SetEmail

Enums 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: RecipeDifficultyUnknown

By 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_UNSPECIFIED

For 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
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
        - UpdatedAt

Use 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
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: RecipeDifficulty

This 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

Known Limitations

  • 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 error type 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_package output, 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.

Future Enhancements

  • 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 view
    • morph 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 scan or something?
  • Package-local helpers could support cross-package mappings involving unexported fields.
  • Built-in helpers which can be used for discovery en masse

License

MIT

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func MapperName

func MapperName(input NameInput, templ string) (string, error)

MapperName attempts to return a name for the given NameInput using Go's text/template library, with NameInput as the template data.

Types

type Config

type Config = config.Config

Config is Morph's user-written/defaultable configuration model.

type Engine

type Engine struct {
	// contains filtered or unexported fields
}

Engine acts as the library entrypoint to Morph, exposing functionality found in Morph's sub-packages from a convenient location.

func New

func New(workingDir string) *Engine

New creates a new Morph Engine with the supplied working directory. The working directory is not the process's working directory, it's the directory that Morph is operating within. For CLI use, this is usually the location of the configuration file.

func (*Engine) Generate

func (e *Engine) Generate(spec Spec, ident string) ([]OutputFile, Plan, error)

Generate generates a Plan for the supplied Spec, then generates output for that Plan.

func (*Engine) GeneratePlan

func (e *Engine) GeneratePlan(plan Plan) ([]OutputFile, error)

GeneratePlan generates output files for the supplied Plan.

func (*Engine) Plan

func (e *Engine) Plan(spec Spec, ident string) (Plan, error)

Plan generates a Plan for the supplied Spec using a new Planner.

type Generator

type Generator struct{}

Generator turns Morph plans into formatted Go source files.

func NewGenerator

func NewGenerator() *Generator

NewGenerator returns a new Generator.

func (*Generator) Generate

func (g *Generator) Generate(p Plan) ([]OutputFile, error)

Generate turns a plan into formatted Go source files.

type NameInput

type NameInput struct {
	Source    types.Type
	Target    types.Type
	Signature spec.MapperSignature
	RunHash   string
}

NameInput is a type used to collect information used for templating a mapper function name.

type OutputFile

type OutputFile struct {
	LogicalPath string
	PackageName string // Used for debugging
	Source      []byte
}

OutputFile is an in-memory representation of a generated file, ready to be written, detailing where the file should be written to, and its contents.

type Plan

type Plan struct {
	OutputGroups []plan.OutputGroup
	Diagnostics  []plan.Diagnostic
}

Plan is the output of the planning process, providing an abstract representation of what to generate and how. The plan can be particularly useful for consumers of Morph as a library, where it can feed into a multi-stage generation process, detailing what will be made available, and how, from Morph.

func (Plan) HasFatalDiagnostics

func (p Plan) HasFatalDiagnostics() bool

HasFatalDiagnostics returns whether this plan contains diagnostics that should prevent code generation.

type Planner

type Planner struct {
	// contains filtered or unexported fields
}

func NewPlanner

func NewPlanner(specification Spec, workingDir, ident string) *Planner

NewPlanner returns a new Planner, set to plan the given Spec.

func (*Planner) Plan

func (p *Planner) Plan() (Plan, error)

Plan attempts to produce a Plan for the Spec assigned to this Planner.

type Spec

type Spec = spec.Spec

Spec is Morph's fully resolved, planner-ready semantic model.

func ResolveConfig

func ResolveConfig(cfg Config) (Spec, error)

ResolveConfig turns user-written config into a fully resolved planner-ready spec.

type Workspace

type Workspace struct {
	// ModuleDir is the logical filesystem location of the current main module
	ModuleDir string
	// ModulePath is the root "import path" of the current main module
	ModulePath string
	// WorkingDir is the current "working directory" of Morph, this is the directory Morph is told
	// it is running in, and is not the same as the process's working directory.
	WorkingDir string
}

Workspace is a representation of the filesystem state that Morph is operating within. This is used to resolve file paths for things like output locations.

Directories

Path Synopsis
cmd
morph command
cli
lab
planner/from
Code generated by morph; DO NOT EDIT.
Code generated by morph; DO NOT EDIT.
planner/mapping
Code generated by morph; DO NOT EDIT.
Code generated by morph; DO NOT EDIT.
Package runtime contains small support helpers used by Morph and by generated code.
Package runtime contains small support helpers used by Morph and by generated code.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL