Skip to content

Conversation

@moutonjeremy
Copy link
Member

Improve error handling for JSON type mismatches by adding detailed field identification and expanding test coverage for new cases. This ensures clearer validation errors when input types do not match expected struct fields.

Copilot AI review requested due to automatic review settings December 3, 2025 14:04
@moutonjeremy moutonjeremy merged commit e5d4465 into main Dec 3, 2025
10 checks passed
@moutonjeremy moutonjeremy deleted the chore-improve-json-parsing branch December 3, 2025 14:08
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR enhances JSON type mismatch error handling by introducing detailed field identification in error messages and expanding test coverage. When clients send incorrect JSON types (e.g., sending a number for a string field), the system now provides clear error messages like "invalid type for field 'name': expected string but got number" instead of raw Go unmarshaling errors.

Key Changes:

  • Added detectTypeMismatchFromBody() function to identify which field caused type mismatches by re-parsing the JSON body
  • Added getJSONValueType() helper to determine the JSON type of values
  • Expanded test coverage with 100+ new test cases covering all primitive types (string, int, float, bool), arrays, and maps

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 11 comments.

File Description
common.go Implements enhanced error detection logic with two new helper functions for identifying and reporting JSON type mismatches with user-friendly field names and type information
json_type_error_test.go Adds comprehensive test coverage for type mismatch scenarios across string, int, float, bool, array, and map fields, including a new TestAllTypeMismatches test function

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

}

if mismatch {
return field.Name, expectedTypeName, actualValueType
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function returns the Go struct field name (e.g., "Name", "Age") instead of the JSON field name (e.g., "name", "age") that clients actually use in their requests. This makes error messages confusing since the API consumer sends JSON with lowercase field names but receives errors referencing capitalized struct field names.

The function should return jsonTag instead of field.Name to match what the client actually sent:

if mismatch {
    return jsonTag, expectedTypeName, actualValueType
}
Suggested change
return field.Name, expectedTypeName, actualValueType
return jsonTag, expectedTypeName, actualValueType

Copilot uses AI. Check for mistakes.
mismatch = true
}
case reflect.Slice, reflect.Array:
expectedTypeName = fmt.Sprintf("[]%s", dereferenceType(expectedFieldType.Elem()).Kind())
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The expected type format for slices (e.g., "[]string") differs from the format for maps (just "map"). The slice type includes the element type using reflection Kind() which produces types like "[]string", "[]int", but this is inconsistent with the map handling.

Additionally, using Kind() directly produces Go-style type names (like "string", "int") but for consistency with JSON/API terminology, consider using the same type mapping logic as in getJSONValueType to produce types like "[]string" for string arrays.

Suggested change
expectedTypeName = fmt.Sprintf("[]%s", dereferenceType(expectedFieldType.Elem()).Kind())
elemType := dereferenceType(expectedFieldType.Elem())
expectedTypeName = fmt.Sprintf("[]%s", getJSONValueType(reflect.Zero(elemType).Interface()))

Copilot uses AI. Check for mistakes.
}

// Check if this field is in the body map
if actualValue, exists := bodyMap[jsonTag]; exists {
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function checks if actualValue exists in bodyMap, but when a field is explicitly set to null in JSON, it will exist in the map with a nil value. The current logic would attempt to check type mismatches for null values, which are typically valid for any field and shouldn't be reported as type mismatches.

Consider skipping null values:

if actualValue, exists := bodyMap[jsonTag]; exists {
    // Skip null values as they're generally valid for optional fields
    if actualValue == nil {
        continue
    }
    expectedFieldType := dereferenceType(field.Type)
    // ... rest of the logic
}
Suggested change
if actualValue, exists := bodyMap[jsonTag]; exists {
if actualValue, exists := bodyMap[jsonTag]; exists {
// Skip null values as they're generally valid for optional fields
if actualValue == nil {
continue
}

Copilot uses AI. Check for mistakes.
}

// detectTypeMismatchFromBody attempts to identify which field caused a JSON type mismatch
// by parsing the request body and comparing against the expected struct type
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The function documentation is minimal and doesn't explain important details about its behavior, such as:

  • What happens when the body can't be parsed
  • The return value semantics (empty strings indicate no mismatch found)
  • The type name conventions used (e.g., "integer" for int, "boolean" for bool)

Consider expanding the documentation:

// detectTypeMismatchFromBody attempts to identify which field caused a JSON type mismatch
// by parsing the request body and comparing against the expected struct type.
// It returns the field name (using JSON tag name), expected type name (using JSON-style names
// like "integer", "boolean", "number"), and actual type name. If no mismatch is found or if
// the body cannot be parsed, it returns empty strings for all three values.
Suggested change
// by parsing the request body and comparing against the expected struct type
// by parsing the request body and comparing against the expected struct type.
// It returns the field name (using JSON tag name), expected type name (using JSON-style names
// like "integer", "boolean", "number"), and actual type name. If no mismatch is found or if
// the body cannot be parsed, it returns empty strings for all three values.

Copilot uses AI. Check for mistakes.
mismatch = true
}
case reflect.Map:
expectedTypeName = "map"
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type name for maps is inconsistent with the type names used for other types. This function returns "map" for map types, but the tests expect field type names like "map[string]string". This inconsistency will cause test failures when map type mismatches occur.

Consider using a more descriptive type name that matches the actual Go type:

case reflect.Map:
    keyType := dereferenceType(expectedFieldType.Key()).Kind()
    valueType := dereferenceType(expectedFieldType.Elem()).Kind()
    expectedTypeName = fmt.Sprintf("map[%s]%s", keyType, valueType)
    if actualValueType != "object" {
        mismatch = true
    }
Suggested change
expectedTypeName = "map"
keyType := dereferenceType(expectedFieldType.Key()).Kind().String()
valueType := dereferenceType(expectedFieldType.Elem()).Kind().String()
expectedTypeName = fmt.Sprintf("map[%s]%s", keyType, valueType)

Copilot uses AI. Check for mistakes.
switch value.(type) {
case string:
return "string"
case float64, int, int64:
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSON only has one numeric type (number), so when JSON is unmarshaled, all numbers become float64. The case statement case float64, int, int64: will only match float64 in practice since int and int64 cannot result from json.Unmarshal into map[string]interface{}.

The int and int64 cases are unreachable and can be removed:

case float64:
    return "number"
Suggested change
case float64, int, int64:
case float64:

Copilot uses AI. Check for mistakes.
Comment on lines +358 to +441
tests := []struct {
name string
body string
expectedField string
expectedType string
actualType string
}{
{
name: "String field receives number",
body: `{"name": 123}`,
expectedField: "name",
expectedType: "string",
actualType: "number",
},
{
name: "String field receives bool",
body: `{"name": true}`,
expectedField: "name",
expectedType: "string",
actualType: "bool",
},
{
name: "Int field receives string",
body: `{"age": "25"}`,
expectedField: "age",
expectedType: "int",
actualType: "string",
},
{
name: "Bool field receives string",
body: `{"active": "true"}`,
expectedField: "active",
expectedType: "bool",
actualType: "string",
},
{
name: "Bool field receives number",
body: `{"active": 1}`,
expectedField: "active",
expectedType: "bool",
actualType: "number",
},
{
name: "Array field receives string",
body: `{"tags": "tag1"}`,
expectedField: "tags",
expectedType: "[]string",
actualType: "string",
},
{
name: "Array field receives object",
body: `{"tags": {"key": "value"}}`,
expectedField: "tags",
expectedType: "[]string",
actualType: "object",
},
{
name: "Map field receives string",
body: `{"metadata": "data"}`,
expectedField: "metadata",
expectedType: "map[string]string",
actualType: "string",
},
{
name: "Map field receives array",
body: `{"metadata": ["a", "b"]}`,
expectedField: "metadata",
expectedType: "map[string]string",
actualType: "array",
},
{
name: "Float field receives string",
body: `{"score": "99.5"}`,
expectedField: "score",
expectedType: "float64",
actualType: "string",
},
{
name: "Float field receives bool",
body: `{"score": true}`,
expectedField: "score",
expectedType: "float64",
actualType: "bool",
},
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test expects expectedType to be "int" (line 383), but the detectTypeMismatchFromBody function in common.go returns "integer" for int fields (line 508). This mismatch will cause the test to fail.

The test expectations should match the actual error message format. Update the expected types to:

  • "integer" instead of "int" (line 383)
  • "boolean" instead of "bool" (lines 390, 397)
  • "number" instead of "float64" (lines 432, 439)

Copilot uses AI. Check for mistakes.
jsonTag = field.Name
} else {
// Remove omitempty and other options from the tag
jsonTag = strings.Split(jsonTag, ",")[0]
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSON tag parsing doesn't handle the special case where the JSON tag is "-", which indicates that the field should be ignored during JSON marshaling/unmarshaling. If a field has json:"-", it should be skipped entirely rather than using "-" as the field name.

Add a check after parsing the JSON tag:

// Remove omitempty and other options from the tag
jsonTag = strings.Split(jsonTag, ",")[0]

// Skip fields with json:"-" tag (they're not serialized)
if jsonTag == "-" {
    continue
}
Suggested change
jsonTag = strings.Split(jsonTag, ",")[0]
jsonTag = strings.Split(jsonTag, ",")[0]
// Skip fields with json:"-" tag (they're not serialized)
if jsonTag == "-" {
continue
}

Copilot uses AI. Check for mistakes.
Comment on lines +460 to +465
func detectTypeMismatchFromBody(body []byte, input interface{}) (fieldName, expectedType, actualType string) {
// Parse the JSON body into a map to see what was actually sent
var bodyMap map[string]interface{}
if err := json.Unmarshal(body, &bodyMap); err != nil {
return "", "", ""
}
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The detectTypeMismatchFromBody function unmarshals the request body a second time (after it already failed to unmarshal into the struct) to detect which field caused the type mismatch. This additional unmarshaling operation adds performance overhead on every type mismatch error.

While this is acceptable for error cases (which should be rare), consider documenting this trade-off. Additionally, the function could be optimized to only unmarshal when necessary by checking the error message pattern first.

Copilot uses AI. Check for mistakes.
Comment on lines +500 to +537
switch expectedFieldType.Kind() {
case reflect.String:
expectedTypeName = "string"
if actualValueType != "string" {
mismatch = true
}
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
expectedTypeName = "integer"
if actualValueType != "number" {
mismatch = true
}
case reflect.Float32, reflect.Float64:
expectedTypeName = "number"
if actualValueType != "number" {
mismatch = true
}
case reflect.Bool:
expectedTypeName = "boolean"
if actualValueType != "boolean" {
mismatch = true
}
case reflect.Slice, reflect.Array:
expectedTypeName = fmt.Sprintf("[]%s", dereferenceType(expectedFieldType.Elem()).Kind())
if actualValueType != "array" {
mismatch = true
}
case reflect.Map:
expectedTypeName = "map"
if actualValueType != "object" {
mismatch = true
}
case reflect.Struct:
expectedTypeName = "object"
if actualValueType != "object" {
mismatch = true
}
}
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The type name mapping logic (string -> "string", int/int64/etc -> "integer", float -> "number", bool -> "boolean") is duplicated between the detectTypeMismatchFromBody function (lines 500-537) and the getSchemaForType function (lines 420-448). This duplication increases maintenance burden.

Consider extracting this mapping into a shared helper function that both can use, such as:

func getJSONTypeName(t reflect.Type) string {
    switch dereferenceType(t).Kind() {
    case reflect.String:
        return "string"
    case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
        reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
        return "integer"
    case reflect.Float32, reflect.Float64:
        return "number"
    case reflect.Bool:
        return "boolean"
    // ... etc
    }
}

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants