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>
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
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-fixAll 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
-
Modify the tsFieldType function to handle nullable fields:
- When a field has
nullable=true, returnT | nullinstead ofT? - The field should NOT be optional (
?) when nullable - it's always present with either value or null
- When a field has
-
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)
}- 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:
- 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")
}- 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-fixAll builds and tests pass, lint clean.
- TypeScript generator produces
fieldName: T | nullfor nullable fields - TypeScript generator produces
fieldName?: Tfor 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
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.protoRun 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/...<success_criteria>
- Nullable annotation on proto3 optional primitive generates null emission in JSON
- TypeScript types correctly show
T | nullunion 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>
