Implement nullable primitive support across all 4 generators (go-http, go-client, ts-client, openapiv3).

Purpose: Enable developers to express explicit null semantics for optional primitive fields. When nullable=true, unset fields serialize as null instead of being omitted from JSON. This provides three distinct states: absent, null, and value.

Output: Go generators produce MarshalJSON/UnmarshalJSON that emit null for unset nullable fields. TypeScript generator produces T | null union types. OpenAPI generator produces type: ["T", "null"] schemas.

<execution_context> @/Users/sebastienmelki/.claude/get-shit-done/workflows/execute-plan.md @/Users/sebastienmelki/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/05-json-nullable-empty/05-CONTEXT.md @.planning/phases/05-json-nullable-empty/05-RESEARCH.md @.planning/phases/05-json-nullable-empty/05-01-SUMMARY.md

Existing encoding patterns to follow

@internal/httpgen/encoding.go @internal/clientgen/encoding.go @internal/tsclientgen/types.go @internal/openapiv3/types.go

Annotation functions from Plan 01#

@internal/annotations/nullable.go

Task 1: Implement nullable in Go generators (go-http and go-client) internal/httpgen/nullable.go internal/httpgen/generator.go internal/clientgen/nullable.go internal/clientgen/generator.go Create internal/httpgen/nullable.go following the pattern in encoding.go:
package httpgen
 
import (
    "io"
    "os"
    "strings"
 
    "google.golang.org/protobuf/compiler/protogen"
 
    "github.com/SebastienMelki/sebuf/internal/annotations"
)
 
// NullableContext holds information about messages that need custom JSON encoding
// for nullable primitive fields.
type NullableContext struct {
    Message        *protogen.Message
    NullableFields []*protogen.Field
}
 
// hasNullableFields returns true if any field in the message has nullable=true.
func hasNullableFields(message *protogen.Message) bool {
    for _, field := range message.Fields {
        if annotations.IsNullableField(field) {
            return true
        }
    }
    return false
}
 
// getNullableFields returns all fields with nullable=true annotation.
func getNullableFields(message *protogen.Message) []*protogen.Field {
    var fields []*protogen.Field
    for _, field := range message.Fields {
        if annotations.IsNullableField(field) {
            fields = append(fields, field)
        }
    }
    return fields
}
 
// collectNullableContext analyzes messages in a file and collects nullable field information.
func collectNullableContext(file *protogen.File) []*NullableContext {
    var contexts []*NullableContext
    collectNullableMessages(file.Messages, &contexts)
    return contexts
}
 
// collectNullableMessages recursively collects messages with nullable fields.
func collectNullableMessages(messages []*protogen.Message, contexts *[]*NullableContext) {
    for _, msg := range messages {
        if hasNullableFields(msg) {
            *contexts = append(*contexts, &NullableContext{
                Message:        msg,
                NullableFields: getNullableFields(msg),
            })
        }
        // Check nested messages
        collectNullableMessages(msg.Messages, contexts)
    }
}
 
// validateNullableAnnotations validates all nullable annotations in a file.
// Returns the first validation error encountered, or nil if all valid.
func validateNullableAnnotations(file *protogen.File) error {
    return validateNullableInMessages(file.Messages)
}
 
func validateNullableInMessages(messages []*protogen.Message) error {
    for _, msg := range messages {
        for _, field := range msg.Fields {
            if err := annotations.ValidateNullableAnnotation(field, msg.GoIdent.GoName); err != nil {
                return err
            }
        }
        // Validate nested messages
        if err := validateNullableInMessages(msg.Messages); err != nil {
            return err
        }
    }
    return nil
}
 
// generateNullableEncodingFile generates the *_nullable.pb.go file if needed.
func (g *Generator) generateNullableEncodingFile(file *protogen.File) error {
    // First validate all nullable annotations
    if err := validateNullableAnnotations(file); err != nil {
        return err
    }
 
    contexts := collectNullableContext(file)
    if len(contexts) == 0 {
        return nil
    }
 
    filename := file.GeneratedFilenamePrefix + "_nullable.pb.go"
    gf := g.plugin.NewGeneratedFile(filename, file.GoImportPath)
 
    g.writeHeader(gf, file)
    g.writeNullableImports(gf)
 
    for _, ctx := range contexts {
        g.generateNullableMarshalJSON(gf, ctx)
        g.generateNullableUnmarshalJSON(gf, ctx)
    }
 
    return nil
}
 
func (g *Generator) writeNullableImports(gf *protogen.GeneratedFile) {
    gf.P("import (")
    gf.P(`"encoding/json"`)
    gf.P()
    gf.P(`"google.golang.org/protobuf/encoding/protojson"`)
    gf.P(")")
    gf.P()
}
 
// generateNullableMarshalJSON generates MarshalJSON that emits null for unset nullable fields.
func (g *Generator) generateNullableMarshalJSON(gf *protogen.GeneratedFile, ctx *NullableContext) {
    msgName := ctx.Message.GoIdent.GoName
 
    var fieldNames []string
    for _, f := range ctx.NullableFields {
        fieldNames = append(fieldNames, string(f.Desc.Name()))
    }
 
    gf.P("// MarshalJSON implements json.Marshaler for ", msgName, ".")
    gf.P("// This method handles nullable fields: ", strings.Join(fieldNames, ", "))
    gf.P("func (x *", msgName, ") MarshalJSON() ([]byte, error) {")
    gf.P("if x == nil {")
    gf.P("return []byte(\"null\"), nil")
    gf.P("}")
    gf.P()
 
    gf.P("// Use protojson for base serialization")
    gf.P("data, err := protojson.Marshal(x)")
    gf.P("if err != nil {")
    gf.P("return nil, err")
    gf.P("}")
    gf.P()
 
    gf.P("// Parse into a map to handle nullable fields")
    gf.P("var raw map[string]json.RawMessage")
    gf.P("if err := json.Unmarshal(data, &raw); err != nil {")
    gf.P("return nil, err")
    gf.P("}")
    gf.P()
 
    // For each nullable field, emit null when not set
    for _, field := range ctx.NullableFields {
        jsonName := field.Desc.JSONName()
        goName := field.GoName
 
        gf.P("// Handle nullable field: ", field.Desc.Name())
        gf.P("// proto3 optional + nullable=true: emit null when not set")
        gf.P("if x.", goName, " == nil {")
        gf.P(`raw["`, jsonName, `"] = []byte("null")`)
        gf.P("}")
        gf.P()
    }
 
    gf.P("return json.Marshal(raw)")
    gf.P("}")
    gf.P()
}
 
// generateNullableUnmarshalJSON generates UnmarshalJSON that accepts null for nullable fields.
func (g *Generator) generateNullableUnmarshalJSON(gf *protogen.GeneratedFile, ctx *NullableContext) {
    msgName := ctx.Message.GoIdent.GoName
 
    var fieldNames []string
    for _, f := range ctx.NullableFields {
        fieldNames = append(fieldNames, string(f.Desc.Name()))
    }
 
    gf.P("// UnmarshalJSON implements json.Unmarshaler for ", msgName, ".")
    gf.P("// This method handles nullable fields: ", strings.Join(fieldNames, ", "))
    gf.P("func (x *", msgName, ") UnmarshalJSON(data []byte) error {")
    gf.P("// Parse to check for explicit null values on nullable fields")
    gf.P("var raw map[string]json.RawMessage")
    gf.P("if err := json.Unmarshal(data, &raw); err != nil {")
    gf.P("return err")
    gf.P("}")
    gf.P()
 
    // For nullable fields, remove explicit nulls before protojson unmarshal
    // protojson doesn't handle null for scalar optionals, so we remove them
    for _, field := range ctx.NullableFields {
        jsonName := field.Desc.JSONName()
 
        gf.P("// Handle nullable field: ", field.Desc.Name())
        gf.P("// Remove explicit null so protojson leaves field unset")
        gf.P(`if rawVal, ok := raw["`, jsonName, `"]; ok && string(rawVal) == "null" {`)
        gf.P(`delete(raw, "`, jsonName, `")`)
        gf.P("}")
        gf.P()
    }
 
    gf.P("// Re-marshal without nulls for protojson")
    gf.P("modified, err := json.Marshal(raw)")
    gf.P("if err != nil {")
    gf.P("return err")
    gf.P("}")
    gf.P()
    gf.P("return protojson.Unmarshal(modified, x)")
    gf.P("}")
    gf.P()
}

Update internal/httpgen/generator.go to call generateNullableEncodingFile in the Generate method (after generateInt64EncodingFile).

Create internal/clientgen/nullable.go with identical implementation (copy from httpgen, change package name to clientgen).

Update internal/clientgen/generator.go to call generateNullableEncodingFile.

IMPORTANT: The Go generators generate MarshalJSON/UnmarshalJSON. If a message already has int64 NUMBER encoding AND nullable fields, the executors will need to combine them into a single MarshalJSON. For now, generate separate files - combination will be handled in a future refactor if needed.

go build ./internal/httpgen/... ./internal/clientgen/...
go test -v ./internal/httpgen/... ./internal/clientgen/...
make lint-fix

All builds and tests pass, lint clean.

  • internal/httpgen/nullable.go exists with generateNullableMarshalJSON and generateNullableUnmarshalJSON
  • internal/clientgen/nullable.go exists with identical implementation
  • Both generators call generateNullableEncodingFile in their Generate method
  • Validation errors are returned for invalid nullable annotations
  • Generated code emits null for unset nullable fields, accepts null on unmarshal
Task 2: Implement nullable in TypeScript and OpenAPI generators internal/tsclientgen/types.go internal/tsclientgen/generator.go internal/openapiv3/types.go Update internal/tsclientgen/types.go:
  1. Modify the tsFieldType function to handle nullable fields:

    • When a field has nullable=true, return T | null instead of T?
    • The field should NOT be optional (?) when nullable - it's always present with either value or null
  2. Add a helper function:

// isNullableField returns true if the field should be typed as T | null in TypeScript.
// This applies to fields with nullable=true annotation on proto3 optional primitives.
func isNullableField(field *protogen.Field) bool {
    return annotations.IsNullableField(field)
}
  1. In the interface field generation, handle nullable:
// For nullable fields: fieldName: T | null (not optional, always present)
// For optional non-nullable: fieldName?: T (optional, may be absent)
if isNullableField(field) {
    // Nullable: always present, value or null
    fieldType := tsFieldTypeBase(field) // Get base type without "?"
    return fmt.Sprintf("%s: %s | null", fieldName, fieldType)
} else if isOptionalField(field) {
    // Optional: may be absent
    return fmt.Sprintf("%s?: %s", fieldName, tsFieldType(field))
}

Update internal/openapiv3/types.go:

  1. Modify convertScalarField to handle nullable fields:
// After building the base schema, check for nullable annotation
if annotations.IsNullableField(field) && field.Desc.HasOptionalKeyword() {
    // OpenAPI 3.1: use type array for nullable
    // Change from type: ["string"] to type: ["string", "null"]
    schema.Type = append(schema.Type, "null")
}
  1. Add the nullable type handling in convertField:
// Handle optional fields (proto3 optional)
schema := g.convertScalarField(field)
if field.Desc.HasOptionalKeyword() {
    // Check for nullable annotation
    if annotations.IsNullableField(field) {
        // Nullable fields use type array: ["T", "null"]
        // This is handled in convertScalarField
    }
}

Add import for annotations package if not already imported.

go build ./internal/tsclientgen/... ./internal/openapiv3/...
go test -v ./internal/tsclientgen/... ./internal/openapiv3/...
make lint-fix

All builds and tests pass, lint clean.

  • TypeScript generator produces fieldName: T | null for nullable fields
  • TypeScript generator produces fieldName?: T for optional non-nullable fields
  • OpenAPI generator produces type: ["string", "null"] for nullable string fields
  • OpenAPI generator produces type: ["integer", "null"] for nullable int fields
  • Both generators use annotations.IsNullableField for detection
Task 3: Add nullable test proto and golden file tests internal/httpgen/testdata/nullable.proto internal/tsclientgen/testdata/nullable.proto internal/openapiv3/testdata/nullable.proto Create test proto files for nullable annotation testing.

Create internal/httpgen/testdata/nullable.proto (and symlink for other generators):

syntax = "proto3";
 
package test.nullable;
 
import "sebuf/http/annotations.proto";
 
option go_package = "github.com/SebastienMelki/sebuf/test/nullable;nullable";
 
// User demonstrates nullable primitive fields
message User {
  // Required field (not nullable)
  string id = 1;
 
  // Optional field with nullable=true - serializes as null when unset
  optional string middle_name = 2 [(sebuf.http.nullable) = true];
 
  // Optional field without nullable - omitted when unset
  optional string nickname = 3;
 
  // Nullable integer field
  optional int32 age = 4 [(sebuf.http.nullable) = true];
 
  // Nullable boolean field
  optional bool is_verified = 5 [(sebuf.http.nullable) = true];
}
 
// NullableService tests nullable fields in requests and responses
service NullableService {
  option (sebuf.http.service_config) = {
    base_path: "/api/v1"
  };
 
  rpc GetUser(GetUserRequest) returns (User) {
    option (sebuf.http.config) = {
      path: "/users/{id}"
      method: HTTP_METHOD_GET
    };
  }
 
  rpc UpdateUser(UpdateUserRequest) returns (User) {
    option (sebuf.http.config) = {
      path: "/users/{id}"
      method: HTTP_METHOD_PUT
    };
  }
}
 
message GetUserRequest {
  string id = 1;
}
 
message UpdateUserRequest {
  string id = 1;
  User user = 2;
}

Create symlinks for ts-client and openapiv3 testdata directories:

cd internal/tsclientgen/testdata && ln -sf ../../httpgen/testdata/nullable.proto nullable.proto
cd internal/openapiv3/testdata && ln -sf ../../httpgen/testdata/nullable.proto nullable.proto

Run golden file test generation to create expected output files.

# Run tests to generate golden files
UPDATE_GOLDEN=1 go test -run TestExhaustiveGoldenFiles ./internal/httpgen/...
UPDATE_GOLDEN=1 go test -run TestExhaustiveGoldenFiles ./internal/tsclientgen/...
UPDATE_GOLDEN=1 go test -run TestExhaustiveGoldenFiles ./internal/openapiv3/...
 
# Verify golden files exist and tests pass
go test -v ./internal/httpgen/... ./internal/tsclientgen/... ./internal/openapiv3/...
- nullable.proto test file exists with various nullable field types - Golden files generated for all 3 generators - Tests pass with expected nullable output (null emission, T | null types, type arrays)
1. Go generators compile: `go build ./internal/httpgen/... ./internal/clientgen/...` 2. TS/OpenAPI generators compile: `go build ./internal/tsclientgen/... ./internal/openapiv3/...` 3. All tests pass: `go test -v ./internal/...` 4. Lint clean: `make lint-fix` 5. Golden files show correct nullable output: - Go: MarshalJSON emits `"fieldName": null` for unset nullable fields - TypeScript: `fieldName: string | null` (not `fieldName?: string`) - OpenAPI: `type: ["string", "null"]`

<success_criteria>

  • Nullable annotation on proto3 optional primitive generates null emission in JSON
  • TypeScript types correctly show T | null union for nullable fields
  • OpenAPI schemas use type array syntax ["T", "null"] per OpenAPI 3.1 spec
  • Invalid nullable annotations (non-optional, message fields) cause generation errors
  • All existing tests continue to pass (zero regression) </success_criteria>
After completion, create `.planning/phases/05-json-nullable-empty/05-02-SUMMARY.md`