Skip to content

tygorgen: source provider ignores TypeMapping for custom-marshaler/external types (maps to unknown, emits duplicates) #4

Description

@broady

Summary

TypeMapping(...) is silently ignored for any type that implements a custom JSON marshaler. The source provider rewrites such types to unknown during the type walk, before the emit-layer mappings are applied — and the provider never receives the user's TypeMappings at all. This makes it impossible to generate TypeScript for Go APIs built on k8s.io/apimachinery (and similar libraries), where time types like metav1.MicroTime/metav1.Time marshal to RFC3339 strings.

This is the exact scenario tygo's type_mappings: handles by pruning the walk (e.g. metav1.MicroTime: "string"). tygorgen's TypeMapping does not prune.

Repro

package main

import (
	v1alpha1 "github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1"
	"tygor.dev/tygorgen"
)

func main() {
	// v1alpha1.UIResourceStatus.lastDeployTime is metav1.MicroTime (custom marshaler → string)
	_, _ = tygorgen.FromTypes(v1alpha1.UIResource{}).
		Provider("source").
		SingleFile().
		TypeMapping("metav1.MicroTime", "string"). // <-- ignored
		TypeMapping("metav1.Time", "string").      // <-- ignored
		ToDir("./out")
}

Actual

warning: CUSTOM_MARSHALER: type MicroTime implements custom marshaler, mapped to 'unknown'
export interface UIResourceStatus {
  lastDeployTime?: unknown;   // expected: string
  pendingBuildSince?: unknown;
  // ...
}

Expected

The explicit TypeMapping("metav1.MicroTime", "string") should take precedence, yielding lastDeployTime?: string.

Root cause

tygorgen/provider/source_provider.go (convert named type):

// Check for custom marshalers on the named type itself
if b.hasCustomMarshaler(named) {
    // ...
    // Create an alias to PrimitiveAny
    aliasDesc := &ir.AliasDescriptor{
        Name:       ir.GoIdentifier{Name: tn.Name(), Package: pkgPath},
        Underlying: ir.Any(),
        // ...
    }
    b.schema.AddType(aliasDesc)
    return nil
}

SourceInputOptions only carries Packages and RootTypesTypeMappings are passed to the emitter, not the provider — so the walk cannot consult them. Two related symptoms fall out of the same gap:

  1. Custom-marshaler types → unknown even with an explicit mapping (above).
  2. Mapped external structs are still fully walked and emitted. Mapping metav1.ObjectMeta → ObjectMeta (defined in frontmatter) still emits a second ObjectMeta from k8s source plus its transitive junk (ManagedFieldsEntry, OwnerReference, FieldsV1, …), producing duplicate-identifier output. tygo avoids this because type_mappings prune the walk.

Why tygo doesn't hit this

tygo is syntactic and per-package. It walks the AST of the configured package dirs and emits only types textually declared there. A field of type metav1.MicroTime is an *ast.SelectorExpr, and tygo simply substitutes the name (tygo/write.go):

case *ast.SelectorExpr: // e.g. `time.Time`
    longType := fmt.Sprintf("%s.%s", t.X, t.Sel) // "metav1.MicroTime"
    mappedTsType, ok := g.conf.TypeMappings[longType]
    if ok { s.WriteString(mappedTsType) }        // -> "string"
    else  { /* fallback `any /* metav1.MicroTime */` */ }

It never resolves metav1.MicroTime to its definition, never recurses into apimachinery, and never sees the custom marshaler. The mapping is a pruning boundary by construction.

tygorgen's source provider is semantic (go/types): it resolves and walks the full reachable type graph, introspects marshalers, and emits reachable types. Because mappings are emit-only, the walk reaches external types it should have treated as opaque.

Suggested fix

Thread TypeMappings into SourceInputOptions and consult them in convertNamedType (and the struct-field walk): if a named type matches a user mapping, emit a mapped reference and do not descend into or emit the type. This makes TypeMapping a true pruning boundary, matching tygo semantics.

Workaround

Vendor the upstream tygo-generated .d.ts (which already maps these correctly) until mappings prune the source walk.

Environment

  • tygor v0.8.6
  • metav1 types from k8s.io/apimachinery (via github.com/tilt-dev/tilt pkg/apis/core/v1alpha1)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions