The error means you're trying to serialize behavior, not data
You're building a config loader. You have a struct with a Name and a Handler function. You call json.Marshal to save it to disk. The program crashes with json: unsupported type: func(). You stare at the code. The struct looks fine. The function works fine. Why does the JSON encoder hate your function?
JSON is a data format. It represents numbers, strings, objects, arrays, booleans, and null. It has no concept of executable code. It has no concept of runtime communication channels. It has no concept of complex numbers. The encoding/json package converts Go values to JSON bytes. It walks your Go values and maps them to JSON types. When it hits something that doesn't map, it stops and reports the error.
Think of JSON as a photograph. A photograph captures the appearance of an object. It captures color, shape, and position. It cannot capture the weight of the object. It cannot capture the sound the object makes. It cannot capture the instructions for building the object. Functions are instructions. Channels are communication pipes. You can photograph a book, but you cannot photograph the act of reading. The encoder is the camera. It takes a picture of your data. When you hand it a function, it has nothing to photograph.
How the encoder walks your values
The json.Marshal function uses reflection to inspect your value. It first checks if the value implements the json.Marshaler interface. The interface defines a single method: MarshalJSON() ([]byte, error). If your type implements this method, the encoder calls it and trusts the result. This is the "accept interfaces, return structs" pattern in action. The encoder accepts the interface to customize behavior. You return a struct or bytes to provide the data.
If the value doesn't implement the interface, the encoder falls back to default behavior. It looks at the underlying kind of the value. Strings, numbers, and booleans map directly. Structs become objects. Slices become arrays. Pointers are dereferenced. The encoder checks the kind of the dereferenced value. Functions, channels, and complex numbers do not map. The encoder returns an error immediately. It does not skip the field. It does not write null. It fails the entire operation.
The error message names the concrete type that caused the failure. If you have a function, the error is json: unsupported type: func(). If you have a channel, the error is json: unsupported type: chan string. The message helps you locate the unsupported field.
JSON is data. Functions are behavior. You can't serialize behavior.
Minimal example: the function trap
package main
import (
"encoding/json"
"fmt"
)
// Config holds application settings.
type Config struct {
Name string
OnLoad func() // Functions cannot be serialized to JSON.
}
func main() {
c := Config{
Name: "my-app",
OnLoad: func() { fmt.Println("loaded") },
}
// Marshal attempts to convert the struct to JSON bytes.
data, err := json.Marshal(c)
if err != nil {
// The encoder rejects the function with json: unsupported type: func().
fmt.Println("Error:", err)
return
}
fmt.Println(string(data))
}
The encoder walks the Config struct. It sees Name and writes a string. It sees OnLoad and sees a function pointer. JSON has no representation for executable code. The encoder returns an error. The program prints the error and exits.
Realistic fix: implementing MarshalJSON
You often need to serialize a struct that contains runtime state. A Task might have a channel for progress updates. A Client might have a mutex for thread safety. You want to serialize the metadata but exclude the runtime state. Implementing MarshalJSON gives you full control.
package main
import (
"encoding/json"
"fmt"
)
// Task represents a unit of work.
type Task struct {
ID int
Payload string
// StatusChannel sends updates when the task completes.
// Channels are unsupported by the JSON encoder.
StatusChannel chan string
}
// MarshalJSON implements json.Marshaler for Task.
// It returns a JSON representation that excludes the channel.
func (t Task) MarshalJSON() ([]byte, error) {
// Define an alias to avoid infinite recursion.
type Alias Task
// Create a temporary struct with only serializable fields.
return json.Marshal(struct {
Alias
// StatusChannel is omitted intentionally.
}{
Alias: Alias(t),
})
}
func main() {
t := Task{
ID: 42,
Payload: "process data",
StatusChannel: make(chan string, 1),
}
data, err := json.Marshal(t)
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println(string(data))
}
The MarshalJSON method returns a JSON representation of the task. It defines an alias type Alias that has the same fields as Task but no methods. This breaks the cycle. If you called json.Marshal(t) inside the method, it would call MarshalJSON again. The recursion would continue until the stack overflows. The alias prevents this. The method constructs a temporary struct that embeds the alias. The temporary struct has no StatusChannel field. The encoder serializes the safe struct. The output contains ID and Payload but no StatusChannel.
The receiver name is t. Go convention uses one or two letters matching the type. The method is public because the json.Marshaler interface requires a public method. The error handling follows the standard pattern. The if err != nil check is verbose by design. It makes the unhappy path visible.
Implement MarshalJSON to control the output. The alias trick prevents infinite loops.
Pitfalls: interfaces, map keys, and hidden types
Interface values can hide unsupported types. If you have a field of type interface{} and you assign a function to it, the encoder sees the interface. It checks the dynamic type. It finds a function. It fails. The error message is json: unsupported type: func(). The error tells you the concrete type, not the interface type. This helps you find the culprit.
Map keys have restrictions. JSON object keys must be strings. Go maps can have any comparable type as key. The encoder tries to convert map keys to strings. Integer keys become strings automatically. A map with key 123 becomes {"123": "value"}. Struct keys do not convert. If you have a map[struct{}]string, the encoder fails with json: unsupported type: struct {...}. Map keys must be strings, numbers, or types that can be converted to strings. Complex types as keys are forbidden.
Pointers dereference automatically. A *string is supported. The encoder dereferences the pointer and serializes the string. A *func() is not supported. The encoder dereferences the pointer and finds a function. It fails. The error is json: unsupported type: func(). Pointers don't save you from unsupported types.
Check the concrete type in the error message. Interfaces hide the truth until runtime.
Decision: how to handle unsupported types
Use the json:"-" tag when a field should never be serialized or deserialized. This is the simplest fix for fields like mutexes, channels, or function pointers that exist only for runtime behavior. The tag tells the encoder to ignore the field completely.
Use a custom MarshalJSON method when you need to transform the data before serialization. This works when you want to include some fields but exclude others, or when you need to compute a derived value for the JSON output. The method gives you full control over the JSON structure.
Use an alias type inside MarshalJSON when you need to avoid infinite recursion. Defining type Alias MyType breaks the cycle so you can embed the safe fields without triggering the custom marshaler again. This is the standard pattern for custom marshaling.
Use a separate DTO struct when the serialization logic is complex. Create a plain struct with only the fields you need, copy the data over, and marshal the DTO. This keeps your domain model clean and avoids reflection overhead in hot paths.
Use json.Marshal on a subset of fields when you only need partial serialization. Construct a temporary struct or map with the specific values you want to send, rather than trying to filter a large struct at runtime.
Tag it out or transform it. Don't fight the encoder.