Bryan

Bryan

twitter
medium

Go's pitfall in error serialization

Introduction

Guess what the following output is?

package main

import (
 "encoding/json"
 "fmt"
)

func main() {
 jsonStr, _ := json.Marshal(fmt.Errorf("This is an error"))
 fmt.Println(string(jsonStr))
}
Answer Surprisingly, the output is not "This is an error", but rather an empty object {}!

This issue has actually been discussed before, but there has been no official response (perhaps because there are very few places where errors need to be serialized?).

Root Cause

In Go, an error is just an interface, a plain interface with no special characteristics.

The error type returned by fmt.Errorf in Go is defined as:

// errorString is a trivial implementation of error.
type errorString struct {
	s string
}

// or

type wrapError struct {
	msg string
	err error
}

During JSON serialization, the standard struct serialization rules are followed: only fields starting with an uppercase letter are retained, while the rest of the fields are omitted, and the underlying Error method is not called to retrieve the error message. Therefore, the final result is simply {}.

Solution

Read the source code related to JSON serialization: json encode.go.

// newTypeEncoder constructs an encoderFunc for a type.
// The returned encoder only checks CanAddr when allowAddr is true.
func newTypeEncoder(t reflect.Type, allowAddr bool) encoderFunc {
	// If we have a non-pointer value whose type implements
	// Marshaler with a value receiver, then we're better off taking
	// the address of the value - otherwise we end up with an
	// allocation as we cast the value to an interface.
	if t.Kind() != reflect.Pointer && allowAddr && reflect.PointerTo(t).Implements(marshalerType) {
		return newCondAddrEncoder(addrMarshalerEncoder, newTypeEncoder(t, false))
	}
	if t.Implements(marshalerType) {
		return marshalerEncoder
	}
	if t.Kind() != reflect.Pointer && allowAddr && reflect.PointerTo(t).Implements(textMarshalerType) {
		return newCondAddrEncoder(addrTextMarshalerEncoder, newTypeEncoder(t, false))
	}
	if t.Implements(textMarshalerType) {
		return textMarshalerEncoder
	}

	switch t.Kind() {
	case reflect.Bool:
		return boolEncoder
	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
		return intEncoder
	case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
		return uintEncoder
	case reflect.Float32:
		return float32Encoder
	case reflect.Float64:
		return float64Encoder
	case reflect.String:
		return stringEncoder
	case reflect.Interface:
		return interfaceEncoder
	case reflect.Struct:
		return newStructEncoder(t)
	case reflect.Map:
		return newMapEncoder(t)
	case reflect.Slice:
		return newSliceEncoder(t)
	case reflect.Array:
		return newArrayEncoder(t)
	case reflect.Pointer:
		return newPtrEncoder(t)
	default:
		return unsupportedTypeEncoder
	}
}

It can be observed that before performing type checks, it checks whether the type implements the json.Marshaler or encoding.TextMarshaler interface. If it does, it uses the corresponding implementation.

Therefore, implementing the encoding.TextMarshaler interface can solve the problem:

func (e MyError) MarshalText() ([]byte, error) {
	return []byte(e.Error()), nil
}

But it's not over yet!

The above solution seems to solve the problem, but it actually imposes an implicit requirement: we cannot use any error types provided by the Go standard library because we cannot implement the TextMarshaler interface for them.

The optimal solution is to use a third-party library globally. Here, I recommend using my own ee error handling library (github.com/ImSingee/go-ex/ee), which is derived from the official pkg/errors library but has been optimized based on actual needs:

  1. (Compared to the standard library) It wraps all errors with call stack information.
  2. For errors that already have call stack information, it does not overwrite them (to ensure that the deepest call stack information is always available).
  3. Supports specifying skip when using WithStack to use the upper-level stack (for writing utility functions).
  4. The StackTrace and Frame of the stack information can be accessed for external tools (such as log processing libraries) to use in a structured way.
  5. Added Panic function, which automatically generates an error and records the panic position information when called.
  6. All errors implement the TextMarshaler interface, making them serialization-friendly.

Furthermore, even if custom errors are wrapped, there may still be some missed cases. Therefore, a suggestion is to check and replace fields that may be errors before JSON serialization. Here is an example function that can be modified for use as needed:

// Special Check
if err, ok := fields["error"].(error); ok {
	_, tmok := err.(encoding.TextMarshaler)
	_, jmok := err.(json.Marshaler)

	if !tmok && !jmok {
		fields["error"] = err.Error()
	}
}

Summary

The part I'm worst at writing in all my articles is the summary, so this summary is generated by AI 😂

This article mainly explains how to serialize error types in Go. To solve the problem of the error type returned by fmt.Errorf() not conforming to the JSON serialization standard, we need to implement the encoding.TextMarshaler interface. In addition, this article recommends using the third-party library ee, which inherits the advantages of the official pkg/errors library and implements the feature of all error types implementing the TextMarshaler interface. Finally, we provide a code example that can help you avoid checking whether a field is an error type before serialization.

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.