Purpose: Produce type-correct TypeScript output from protoc-gen-ts-client and protoc-gen-ts-server for enum query params, repeated query params, mixed path+query routes, and empty request messages. Output: Fixed generator code, updated golden files, passing tests.
<execution_context> @/Users/sebastienmelki/.claude/get-shit-done/workflows/execute-plan.md @/Users/sebastienmelki/.claude/get-shit-done/templates/summary.md </execution_context>
Add a new enum definition and a new RPC + request message that exercises all bug scenarios. Add these AFTER the existing messages in the proto file:
enum Region {
REGION_UNSPECIFIED = 0;
REGION_AMERICAS = 1;
REGION_EUROPE = 2;
REGION_ASIA = 3;
}
message SearchAdvancedRequest {
// Enum query param (bugs #1 and #2)
Region region = 1 [(sebuf.http.query) = { name: "region" }];
// Repeated string query param (bugs #3 and #4)
repeated string countries = 2 [(sebuf.http.query) = { name: "countries" }];
// Normal string for baseline
string keyword = 3 [(sebuf.http.query) = { name: "keyword" }];
}
// Empty request message (bug #6)
message EmptyRequest {}Add two new RPCs to the QueryParamService:
// Advanced search with enum + repeated params
rpc SearchAdvanced(SearchAdvancedRequest) returns (SearchResponse) {
option (sebuf.http.config) = {
path: "/search/advanced"
method: HTTP_METHOD_GET
};
}
// RPC with empty request message
rpc GetDefaults(EmptyRequest) returns (SearchResponse) {
option (sebuf.http.config) = {
path: "/defaults"
method: HTTP_METHOD_GET
};
}Note: The tsservergen/testdata/proto/query_params.proto is a symlink to httpgen/testdata/proto/query_params.proto (or tsclientgen). Check with ls -la first. If it's NOT a symlink, it's a copy -- update the tsclientgen version (the canonical one shared via symlink) or both files.
1b. Add helper functions to internal/tscommon/types.go:
Add a function TSEnumUnspecifiedValue that returns the first enum value name (the UNSPECIFIED variant) given a *protogen.Field:
// TSEnumUnspecifiedValue returns the first enum value (UNSPECIFIED variant) as a quoted string.
// For enum fields, this is the zero-value equivalent used in TS zero checks.
func TSEnumUnspecifiedValue(field *protogen.Field) string {
if field.Desc.Kind() != protoreflect.EnumKind || field.Enum == nil {
return `""`
}
values := field.Enum.Values
if len(values) == 0 {
return `""`
}
// Check for custom enum_value annotation on the first value
customValue := annotations.GetEnumValueMapping(values[0])
if customValue != "" {
return fmt.Sprintf(`"%s"`, customValue)
}
return fmt.Sprintf(`"%s"`, string(values[0].Desc.Name()))
}Update TSZeroCheckForField to handle enum kind:
Currently it only handles int64 kinds specially and falls back to TSZeroCheck(kind.String()) for everything else. The problem is that kind.String() returns "enum" for enum fields, which falls through to the default !== "" case. This generates !== "" but the TS type is a string union like "REGION_UNSPECIFIED" | "REGION_AMERICAS" where "" is not a valid value.
Add an EnumKind case BEFORE the default:
case protoreflect.EnumKind:
return " !== " + TSEnumUnspecifiedValue(field)Also update TSZeroCheck (the non-field version) to handle the "enum" kind string -- return "" (empty string = use bool check, same as bool) since without the field context we cannot know the UNSPECIFIED value. Note: this fallback path is only used when qp.Field is nil, which is rare.
1c. Add IsRepeatedField check to TSZeroCheckForField:
Before the switch statement in TSZeroCheckForField, add an early return for repeated (list) fields:
// Repeated fields use length check
if field.Desc.IsList() {
return "" // empty string = use the truthy check pattern (like bool)
}This ensures that repeated fields get a simple truthy check rather than a string comparison.
go build ./internal/tscommon/... to verify compilation. Run go test ./internal/tscommon/... if unit tests exist.
TSEnumUnspecifiedValue returns the first enum value name. TSZeroCheckForField returns !== "REGION_UNSPECIFIED" for enum fields and "" (truthy check) for repeated fields.
In generateURLBuilding in internal/tsclientgen/generator.go, the zero check logic already calls tsZeroCheckForField(qp.Field) when qp.Field != nil. The fix from Task 1 handles this -- TSZeroCheckForField now returns !== "REGION_UNSPECIFIED" for enum fields. No additional changes needed in the client generator for this bug, as long as Task 1's TSZeroCheckForField changes are correct.
Verify this by tracing the code path: generateURLBuilding -> tsZeroCheckForField(qp.Field) -> tscommon.TSZeroCheckForField(field) -> new EnumKind case.
Bug #2 -- protoc-gen-ts-server: enum fields assigned from params.get() ?? ""
In generateQueryParamField in internal/tsservergen/generator.go, the default: case in the tsType switch emits params.get("region") ?? "". For enum fields, the type is a string union where "" is not valid.
Fix: Add a new case BEFORE the default in the if qp.Field != nil branch. After the case tscommon.TSBoolean: case and before default:, detect enum fields:
// Check if it's an enum field
if qp.Field != nil && qp.Field.Desc.Kind() == protoreflect.EnumKind && qp.Field.Enum != nil {
unspecified := tscommon.TSEnumUnspecifiedValue(qp.Field)
p(` %s: (params.get("%s") ?? %s) as %s,`, jsonName, paramName, unspecified, string(qp.Field.Enum.Desc.Name()))
} else {
// default string
p(` %s: params.get("%s") ?? "",`, jsonName, paramName)
}This needs to replace the existing default: case in the qp.Field != nil branch. The enum field gets a cast to the enum type and defaults to the UNSPECIFIED value.
Similarly in the qp.Field == nil fallback branch (the else with FieldKind switch), add a case for "enum" kind:
case "enum":
p(` %s: params.get("%s") ?? "",`, jsonName, paramName)(Without field context, we can only default to "" and let TS figure it out.)
Bug #3 -- protoc-gen-ts-server: repeated string fields read as single string
In generateQueryParamField in internal/tsservergen/generator.go, add an early check at the top of the function for repeated fields:
func (g *Generator) generateQueryParamField(p tscommon.Printer, qp annotations.QueryParam) {
jsonName := qp.FieldJSONName
paramName := qp.ParamName
// Handle repeated fields (bug #3)
if qp.Field != nil && qp.Field.Desc.IsList() {
p(` %s: params.getAll("%s"),`, jsonName, paramName)
return
}
// ... existing code ...
}This uses params.getAll() which returns string[], matching the TS interface type for repeated string fields.
Bug #4 -- protoc-gen-ts-client: repeated string fields compared to ""
In generateURLBuilding in internal/tsclientgen/generator.go, the zero check for repeated fields returns "" (truthy check) from Task 1. But the params.set call uses String(req.countries) which would stringify the array wrong.
The fix needs to be in the generation code. After the zero-check guard, when the field is a repeated/list field, use a different serialization strategy. Modify the query param generation loop in generateURLBuilding:
After computing check, add detection for repeated fields:
// Handle repeated fields specially
if qp.Field != nil && qp.Field.Desc.IsList() {
p(" if (req.%s && req.%s.length > 0) req.%s.forEach(v => params.append(\"%s\", v));",
qp.FieldJSONName, qp.FieldJSONName, qp.FieldJSONName, qp.ParamName)
continue // Skip the normal params.set logic below
}Place this BEFORE the if check == "" block, inside the for _, qp := range cfg.queryParams loop. Use continue to skip the standard set logic. This uses params.append for each value so the URL gets ?countries=US&countries=UK.
Bug #5 -- protoc-gen-ts-server: duplicate const url
In internal/tsservergen/generator.go, the generatePathParamExtraction function emits const url = new URL(req.url, "http://localhost"); when there are path params (line 548). Then generateQueryParamParsing ALSO emits const url = new URL(req.url, "http://localhost"); (line 595).
Fix: In generateQueryParamParsing, check if path params already exist. If they do, the url variable is already declared -- just access url.searchParams without re-declaring url.
Modify generateQueryParamParsing to accept the cfg *rpcRouteConfig it already receives, and check len(cfg.pathParams) > 0:
if len(cfg.pathParams) > 0 {
// url already declared in path param extraction
p(" const params = url.searchParams;")
} else {
p(" const url = new URL(req.url, \"http://localhost\");")
p(" const params = url.searchParams;")
}Replace the current two lines:
p(" const url = new URL(req.url, \"http://localhost\");")
p(" const params = url.searchParams;")Bug #6 -- protoc-gen-ts-client: unused req parameter for empty request messages
In generateRPCMethod in internal/tsclientgen/generator.go, at line 272 the signature is:
p(" async %s(req: %s, options?: %sCallOptions): Promise<%s> {", ...)Check if the input message has zero fields. If so, prefix with _:
reqParam := "req"
if len(method.Input.Fields) == 0 {
reqParam = "_req"
}
p(" async %s(%s: %s, options?: %sCallOptions): Promise<%s> {",
tsMethodName, reqParam, inputType, cfg.serviceName, outputType)This only affects the parameter name in the signature. The body already won't reference req since there are no fields to build path/query params from and no body to send for GET methods with empty input.
After all fixes, rebuild and update golden files:
rm -rf bin && make build
UPDATE_GOLDEN=1 go test -run TestTSClientGenGoldenFiles ./internal/tsclientgen/
UPDATE_GOLDEN=1 go test -run TestTSServerGenGoldenFiles ./internal/tsservergen/Then run the full test suite to make sure nothing else broke:
go test ./internal/tsclientgen/ ./internal/tsservergen/ ./internal/tscommon/Then run lint:
make lint-fix<success_criteria>
- TypeScript generated for enum query params compiles without type errors (no
""comparison against string union) - TypeScript generated for repeated query params uses array operations (getAll/forEach+append)
- No duplicate variable declarations in generated server code for mixed path+query routes
- No unused parameter warnings in generated client code for empty request messages
- All existing tests continue to pass (no regressions) </success_criteria>
