Repository: hamba/avro
Pull request #521 (2025-04-15 19:01:29)

feat: add data type conversion for generic encoding/decoding unions
## Goal of this PR

<!-- A brief description of the change being made with this pull request. -->
Extends #515 to allow type conversions for Avro Unions when encoding/decoding Go `any`.

## How did I test it?

Added more unit tests (and split the unit test file into separate files for encode and decode).

Benchmark comparison (10 runs each) looks good; any differences are just noise:
```
                          │   old.txt    │               new.txt                │
                          │    sec/op    │    sec/op     vs base                │
SuperheroDecode-12          595.6n ±  7%   626.1n ±  8%        ~ (p=0.063 n=10)
SuperheroEncode-12          612.3n ±  7%   508.7n ± 27%  -16.91% (p=0.043 n=10)
PartialSuperheroDecode-12   289.9n ± 16%   262.2n ± 13%   -9.54% (p=0.011 n=10)
SuperheroGenericDecode-12   8.184µ ± 29%   9.004µ ± 10%        ~ (p=0.143 n=10)
SuperheroGenericEncode-12   1.054µ ±  5%   1.030µ ± 12%        ~ (p=0.565 n=10)
SuperheroWriteFlush-12      443.2n ± 17%   416.7n ± 22%        ~ (p=0.280 n=10)
geomean                     859.9n         828.2n         -3.69%
```




Patch:
diff --git a/codec_dynamic.go b/codec_dynamic.go
index 644113b..4ac7b97 100644
--- a/codec_dynamic.go
+++ b/codec_dynamic.go
@@ -1,6 +1,7 @@
 package avro
 
 import (
+	"errors"
 	"reflect"
 	"unsafe"
 
@@ -29,7 +30,7 @@ func (d *efaceDecoder) Decode(ptr unsafe.Pointer, r *Reader) {
 
 	defer func() {
 		obj, err := r.cfg.typeConverters.DecodeTypeConvert(*pObj, d.schema)
-		if err != nil {
+		if err != nil && !errors.Is(err, errNoTypeConverter) {
 			r.Error = err
 		}
 		*pObj = obj
@@ -66,7 +67,7 @@ func (e *interfaceEncoder) Encode(ptr unsafe.Pointer, w *Writer) {
 	obj := e.typ.UnsafeIndirect(ptr)
 
 	obj, err := w.cfg.typeConverters.EncodeTypeConvert(obj, e.schema)
-	if err != nil {
+	if err != nil && !errors.Is(err, errNoTypeConverter) {
 		w.Error = err
 		return
 	}
diff --git a/codec_union.go b/codec_union.go
index 30db5c0..cb66a78 100644
--- a/codec_union.go
+++ b/codec_union.go
@@ -161,6 +161,12 @@ func (e *mapUnionEncoder) Encode(ptr unsafe.Pointer, w *Writer) {
 		val = []struct{}{}
 	}
 
+	val, err := w.cfg.typeConverters.EncodeTypeConvert(val, e.schema)
+	if err != nil && !errors.Is(err, errNoTypeConverter) {
+		w.Error = err
+		return
+	}
+
 	elemType := reflect2.TypeOf(val)
 	elemPtr := reflect2.PtrOf(val)
 
@@ -214,6 +220,34 @@ func (d *unionNullableDecoder) Decode(ptr unsafe.Pointer, r *Reader) {
 		return
 	}
 
+	defer func() {
+		if !d.isPtr {
+			obj := d.typ.UnsafeIndirect(ptr)
+			obj, err := r.cfg.typeConverters.DecodeTypeConvert(obj, d.schema)
+			if errors.Is(err, errNoTypeConverter) {
+				return
+			}
+			if err != nil {
+				r.Error = err
+			}
+			if obj == nil {
+				*(*unsafe.Pointer)(ptr) = nil
+				return
+			}
+			d.typ.UnsafeSet(ptr, reflect2.PtrOf(obj))
+			return
+		}
+		obj := d.typ.UnsafeIndirect(*((*unsafe.Pointer)(ptr)))
+		obj, err := r.cfg.typeConverters.DecodeTypeConvert(obj, d.schema)
+		if errors.Is(err, errNoTypeConverter) {
+			return
+		}
+		if err != nil {
+			r.Error = err
+		}
+		*((*unsafe.Pointer)(ptr)) = reflect2.PtrOf(obj)
+	}()
+
 	// Handle the non-ptr case separately.
 	if !d.isPtr {
 		if d.typ.UnsafeIsNil(ptr) {
@@ -347,6 +381,14 @@ func (d *unionResolvedDecoder) Decode(ptr unsafe.Pointer, r *Reader) {
 		return
 	}
 
+	defer func() {
+		obj, err := r.cfg.typeConverters.DecodeTypeConvert(*pObj, d.schema)
+		if err != nil && !errors.Is(err, errNoTypeConverter) {
+			r.Error = err
+		}
+		*pObj = obj
+	}()
+
 	if i >= len(d.decoders) || d.decoders[i] == nil {
 		if d.cfg.config.UnionResolutionError {
 			r.ReportError("decode union type", "unknown union type")
diff --git a/schema.go b/schema.go
index 84a9fd2..eb23930 100644
--- a/schema.go
+++ b/schema.go
@@ -1272,7 +1272,13 @@ func (s *UnionSchema) Types() Schemas {
 	return s.types
 }
 
-// Nullable returns the Schema if the union is nullable, otherwise nil.
+// Contains returns true if the union contains the given type.
+func (s *UnionSchema) Contains(typ Type) bool {
+	_, pos := s.types.Get(string(typ))
+	return pos != -1
+}
+
+// Nullable returns true if the union is nullable, otherwise false.
 func (s *UnionSchema) Nullable() bool {
 	if len(s.types) != 2 || s.types[0].Type() != Null && s.types[1].Type() != Null {
 		return false
diff --git a/typeconverter.go b/typeconverter.go
index 274356d..6af71d3 100644
--- a/typeconverter.go
+++ b/typeconverter.go
@@ -1,9 +1,12 @@
 package avro
 
 import (
+	"errors"
 	"sync"
 )
 
+var errNoTypeConverter = errors.New("no type converter")
+
 // TypeConverter represents a data type converter.
 type TypeConverter interface {
 	// Type returns the Avro data type of the type converter.
@@ -13,10 +16,10 @@ type TypeConverter interface {
 	LogicalType() LogicalType
 
 	// EncodeTypeConvert is the type conversion function called before encoding to Avro.
-	EncodeTypeConvert(in any) (any, error)
+	EncodeTypeConvert(in any, schema Schema) (any, error)
 
 	// DecodeTypeConvert is the type conversion function called after decoding from Avro.
-	DecodeTypeConvert(in any) (any, error)
+	DecodeTypeConvert(in any, schema Schema) (any, error)
 }
 
 type specificType struct {
@@ -48,20 +51,20 @@ func (c *TypeConverters) RegisterTypeConverters(convs ...TypeConverter) {
 func (c *TypeConverters) EncodeTypeConvert(in any, schema Schema) (any, error) {
 	conv, ok := c.getTypeConverter(schema)
 	if !ok {
-		return in, nil
+		return in, errNoTypeConverter
 	}
 
-	return conv.EncodeTypeConvert(in)
+	return conv.EncodeTypeConvert(in, schema)
 }
 
 // DecodeTypeConvert runs the decode type conversion function for the given value and schema.
 func (c *TypeConverters) DecodeTypeConvert(in any, schema Schema) (any, error) {
 	conv, ok := c.getTypeConverter(schema)
 	if !ok {
-		return in, nil
+		return in, errNoTypeConverter
 	}
 
-	return conv.DecodeTypeConvert(in)
+	return conv.DecodeTypeConvert(in, schema)
 }
 
 func (c *TypeConverters) getTypeConverter(schema Schema) (TypeConverter, bool) {
@@ -85,8 +88,8 @@ func RegisterTypeConverters(convs ...TypeConverter) {
 type TypeConversionFuncs struct {
 	AvroType              Type
 	AvroLogicalType       LogicalType
-	EncoderTypeConversion func(in any) (any, error)
-	DecoderTypeConversion func(in any) (any, error)
+	EncoderTypeConversion func(in any, schema Schema) (any, error)
+	DecoderTypeConversion func(in any, schema Schema) (any, error)
 }
 
 // Type returns the Avro data type of the type converter.
@@ -100,17 +103,17 @@ func (c TypeConversionFuncs) LogicalType() LogicalType {
 }
 
 // EncodeTypeConvert runs the converter's encoder type conversion function if it's set.
-func (c TypeConversionFuncs) EncodeTypeConvert(in any) (any, error) {
+func (c TypeConversionFuncs) EncodeTypeConvert(in any, schema Schema) (any, error) {
 	if c.EncoderTypeConversion == nil {
-		return in, nil
+		return in, errNoTypeConverter
 	}
-	return c.EncoderTypeConversion(in)
+	return c.EncoderTypeConversion(in, schema)
 }
 
 // DecodeTypeConvert runs the converter's decoder type conversion function if it's set.
-func (c TypeConversionFuncs) DecodeTypeConvert(in any) (any, error) {
+func (c TypeConversionFuncs) DecodeTypeConvert(in any, schema Schema) (any, error) {
 	if c.DecoderTypeConversion == nil {
-		return in, nil
+		return in, errNoTypeConverter
 	}
-	return c.DecoderTypeConversion(in)
+	return c.DecoderTypeConversion(in, schema)
 }


Test patch:
diff --git a/typeconverter_decode_test.go b/typeconverter_decode_test.go
new file mode 100644
index 0000000..648acf9
--- /dev/null
+++ b/typeconverter_decode_test.go
@@ -0,0 +1,217 @@
+package avro_test
+
+import (
+	"bytes"
+	"errors"
+	"testing"
+
+	"github.com/hamba/avro/v2"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestDecoderTypeConverter_Single(t *testing.T) {
+	defer ConfigTeardown()
+
+	data := []byte{0x01}
+	schema := `{"type":"boolean"}`
+	dec, err := avro.NewDecoder(schema, bytes.NewReader(data))
+	require.NoError(t, err)
+
+	avro.RegisterTypeConverters(boolConverter)
+
+	var got any
+	err = dec.Decode(&got)
+
+	require.NoError(t, err)
+	assert.Equal(t, "yes", got)
+}
+
+func TestDecoderTypeConverter_UnionResolved(t *testing.T) {
+	defer ConfigTeardown()
+
+	data := []byte{0x02, 0x01}
+	schema := `{"type":["null","boolean"]}`
+	dec, err := avro.NewDecoder(schema, bytes.NewReader(data))
+	require.NoError(t, err)
+
+	avro.RegisterTypeConverters(unionConverter)
+
+	var got any
+	err = dec.Decode(&got)
+
+	require.NoError(t, err)
+	assert.Equal(t, "yes", got)
+}
+
+func TestDecoderTypeConverter_MapUnion(t *testing.T) {
+	defer ConfigTeardown()
+
+	data := []byte{0x02, 0x00, 0x00, 0x00, 0x00, 0x87, 0x78}
+	schema := `{"type":["null",{"type":"fixed", "name":"fixed_decimal", "size":6, "logicalType":"decimal", "precision":4, "scale":2}]}`
+	dec, err := avro.NewDecoder(schema, bytes.NewReader(data))
+	require.NoError(t, err)
+
+	avro.RegisterTypeConverters(fixedDecimalConverter)
+
+	var got map[string]any
+
+	err = dec.Decode(&got)
+
+	require.NoError(t, err)
+	assert.Equal(t, map[string]any{"fixed_decimal": 346.8}, got)
+}
+
+func TestDecoderTypeConverter_UnionNullableSlice(t *testing.T) {
+	defer ConfigTeardown()
+
+	data := []byte{0x02, 0x06, 'f', 'o', 'o'}
+	schema := `["null", "bytes"]`
+	dec, _ := avro.NewDecoder(schema, bytes.NewReader(data))
+
+	avro.RegisterTypeConverters(avro.TypeConversionFuncs{
+		AvroType: avro.Union,
+		DecoderTypeConversion: func(in any, schema avro.Schema) (any, error) {
+			s := in.([]byte)
+			for i, b := range s {
+				s[i] = b - 0x20
+			}
+			return in, nil
+		},
+	})
+
+	var got []byte
+	err := dec.Decode(&got)
+
+	want := []byte("FOO")
+	require.NoError(t, err)
+	assert.Equal(t, want, got)
+}
+
+func TestDecoderTypeConverter_UnionNullablePtr(t *testing.T) {
+	defer ConfigTeardown()
+
+	data := []byte{0x02, 0x36}
+	schema := `["null", "int"]`
+	dec, _ := avro.NewDecoder(schema, bytes.NewReader(data))
+
+	avro.RegisterTypeConverters(avro.TypeConversionFuncs{
+		AvroType: avro.Union,
+		DecoderTypeConversion: func(in any, schema avro.Schema) (any, error) {
+			i := in.(int)
+			i = i * 2
+			return i, nil
+		},
+	})
+
+	var got *int
+	err := dec.Decode(&got)
+
+	want := int(54)
+	require.NoError(t, err)
+	assert.Equal(t, want, *got)
+}
+
+func TestDecoderTypeConverter_FixedRat(t *testing.T) {
+	defer ConfigTeardown()
+
+	data := []byte{0x00, 0x00, 0x00, 0x00, 0x87, 0x78}
+	schema := `{"type":"fixed", "name":"fixed_decimal", "size":6, "logicalType":"decimal", "precision":4, "scale":2}`
+	dec, err := avro.NewDecoder(schema, bytes.NewReader(data))
+	require.NoError(t, err)
+
+	avro.RegisterTypeConverters(fixedDecimalConverter)
+
+	var got any
+	err = dec.Decode(&got)
+
+	require.NoError(t, err)
+	assert.Equal(t, float64(346.8), got)
+}
+
+func TestDecoderTypeConverter_NotSet(t *testing.T) {
+	defer ConfigTeardown()
+
+	data := []byte{0x01}
+	schema := `{"type":"boolean"}`
+	dec, err := avro.NewDecoder(schema, bytes.NewReader(data))
+	require.NoError(t, err)
+
+	avro.RegisterTypeConverters(nonConverter(avro.Boolean))
+
+	var got any
+	err = dec.Decode(&got)
+
+	require.NoError(t, err)
+	assert.Equal(t, true, got)
+}
+
+func TestDecoderTypeConverter_Error(t *testing.T) {
+	defer ConfigTeardown()
+
+	data := []byte{0x36}
+	schema := `{"type":"int"}`
+	dec, err := avro.NewDecoder(schema, bytes.NewReader(data))
+	require.NoError(t, err)
+
+	testError := errors.New("test error")
+	avro.RegisterTypeConverters(errorConverter(avro.Int, testError))
+
+	var got any
+	err = dec.Decode(&got)
+
+	assert.ErrorIs(t, err, testError)
+	assert.Nil(t, got)
+}
+
+func TestDecoderTypeConverter_ErrorUnionResolved(t *testing.T) {
+	defer ConfigTeardown()
+
+	data := []byte{0x02, 0x01}
+	schema := `{"type":["null","boolean"]}`
+	dec, err := avro.NewDecoder(schema, bytes.NewReader(data))
+	require.NoError(t, err)
+
+	testError := errors.New("test error")
+	avro.RegisterTypeConverters(errorConverter(avro.Union, testError))
+
+	var got any
+	err = dec.Decode(&got)
+
+	assert.ErrorIs(t, err, testError)
+	assert.Nil(t, got)
+}
+
+func TestDecoderTypeConverter_ErrorUnionNullableSlice(t *testing.T) {
+	defer ConfigTeardown()
+
+	data := []byte{0x02, 0x06, 'f', 'o', 'o'}
+	schema := `["null", "bytes"]`
+	dec, _ := avro.NewDecoder(schema, bytes.NewReader(data))
+
+	testError := errors.New("test error")
+	avro.RegisterTypeConverters(errorConverter(avro.Union, testError))
+
+	var got []byte
+	err := dec.Decode(&got)
+
+	assert.ErrorIs(t, err, testError)
+	assert.Nil(t, got)
+}
+
+func TestDecoderTypeConverter_ErrorUnionNullablePtr(t *testing.T) {
+	defer ConfigTeardown()
+
+	data := []byte{0x02, 0x36}
+	schema := `["null", "int"]`
+	dec, _ := avro.NewDecoder(schema, bytes.NewReader(data))
+
+	testError := errors.New("test error")
+	avro.RegisterTypeConverters(errorConverter(avro.Union, testError))
+
+	var got *int
+	err := dec.Decode(&got)
+
+	assert.ErrorIs(t, err, testError)
+	assert.Nil(t, got)
+}
diff --git a/typeconverter_encode_test.go b/typeconverter_encode_test.go
new file mode 100644
index 0000000..bed481e
--- /dev/null
+++ b/typeconverter_encode_test.go
@@ -0,0 +1,328 @@
+package avro_test
+
+import (
+	"bytes"
+	"errors"
+	"testing"
+
+	"github.com/hamba/avro/v2"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestEncoderTypeConverter_Array(t *testing.T) {
+	defer ConfigTeardown()
+
+	schema := `{"type":"array", "items":"int"}`
+	buf := &bytes.Buffer{}
+	enc, err := avro.NewEncoder(schema, buf)
+	require.NoError(t, err)
+
+	avro.RegisterTypeConverters(intConverter)
+
+	val := []any{
+		float32(27),
+		float64(28),
+	}
+	err = enc.Encode(val)
+
+	require.NoError(t, err)
+	assert.Equal(t, []byte{0x03, 0x04, 0x36, 0x38, 0x0}, buf.Bytes())
+}
+
+func TestEncoderTypeConverter_MapRecordSingle(t *testing.T) {
+	defer ConfigTeardown()
+
+	schema := `{"type":"record", "name":"test", "fields":[{"name":"a", "type":"int"}]}`
+	buf := &bytes.Buffer{}
+	enc, err := avro.NewEncoder(schema, buf)
+	require.NoError(t, err)
+
+	avro.RegisterTypeConverters(intConverter)
+
+	val := map[string]any{
+		"a": float64(27),
+	}
+	err = enc.Encode(val)
+
+	require.NoError(t, err)
+	assert.Equal(t, []byte{0x36}, buf.Bytes())
+}
+
+func TestEncoderTypeConverter_MapRecordNullable(t *testing.T) {
+	defer ConfigTeardown()
+
+	schema := `{"type":"record", "name":"test", "fields":[{"name":"a", "type":["null","int"]}]}`
+	buf := &bytes.Buffer{}
+	enc, err := avro.NewEncoder(schema, buf)
+	require.NoError(t, err)
+
+	avro.RegisterTypeConverters(unionConverter)
+
+	val := map[string]any{
+		"a": float64(27),
+	}
+	err = enc.Encode(val)
+
+	require.NoError(t, err)
+	assert.Equal(t, []byte{0x02, 0x36}, buf.Bytes())
+}
+
+func TestEncoderTypeConverter_MapRecordNullablePtr(t *testing.T) {
+	defer ConfigTeardown()
+
+	schema := `{"type":"record", "name":"test", "fields":[{"name":"a", "type":["null","int"]}]}`
+	buf := &bytes.Buffer{}
+	enc, err := avro.NewEncoder(schema, buf)
+	require.NoError(t, err)
+
+	avro.RegisterTypeConverters(unionConverter)
+
+	f := float64(27)
+	val := map[string]any{
+		"a": &f,
+	}
+	err = enc.Encode(val)
+
+	require.NoError(t, err)
+	assert.Equal(t, []byte{0x02, 0x36}, buf.Bytes())
+}
+
+func TestEncoderTypeConverter_MapRecordUnionResolved(t *testing.T) {
+	defer ConfigTeardown()
+
+	schema := `{"type":"record", "name":"test", "fields":[{"name":"a", "type":["string","int"]}]}`
+	buf := &bytes.Buffer{}
+	enc, err := avro.NewEncoder(schema, buf)
+	require.NoError(t, err)
+
+	avro.RegisterTypeConverters(unionConverter)
+
+	val := map[string]any{
+		"a": float64(27),
+	}
+	err = enc.Encode(val)
+
+	require.NoError(t, err)
+	assert.Equal(t, []byte{0x00, 0x04, 0x32, 0x37}, buf.Bytes())
+}
+
+func TestEncoderTypeConverter_MapRecordMapUnion(t *testing.T) {
+	defer ConfigTeardown()
+
+	schema := `{"type":"record", "name":"test", "fields":[{"name":"a", "type":["null","int"]}]}`
+	buf := &bytes.Buffer{}
+	enc, err := avro.NewEncoder(schema, buf)
+	require.NoError(t, err)
+
+	avro.RegisterTypeConverters(unionConverter)
+
+	val := map[string]any{
+		"a": map[string]any{"int": float64(27)},
+	}
+	err = enc.Encode(val)
+
+	require.NoError(t, err)
+	assert.Equal(t, []byte{0x02, 0x36}, buf.Bytes())
+}
+
+func TestEncoderTypeConverter_MapRecordUnionFixedDecimal(t *testing.T) {
+	defer ConfigTeardown()
+
+	schema := `{"type":"record", "name":"test", "fields":[{"name":"a", "type": {"type":"fixed", "name":"fixed_decimal", "size":6, "logicalType":"decimal", "precision":4, "scale":2}}]}`
+	buf := &bytes.Buffer{}
+	enc, err := avro.NewEncoder(schema, buf)
+	require.NoError(t, err)
+
+	avro.RegisterTypeConverters(fixedDecimalConverter)
+
+	val := map[string]any{
+		"a": "346.8",
+	}
+	err = enc.Encode(val)
+
+	require.NoError(t, err)
+	assert.Equal(t, []byte{0x00, 0x00, 0x00, 0x00, 0x87, 0x78}, buf.Bytes())
+}
+
+func TestEncoderTypeConverter_StructRecordSingle(t *testing.T) {
+	defer ConfigTeardown()
+
+	schema := `{"type":"record", "name":"test", "fields":[{"name":"a", "type":"int"}]}`
+	buf := &bytes.Buffer{}
+	enc, err := avro.NewEncoder(schema, buf)
+	require.NoError(t, err)
+
+	avro.RegisterTypeConverters(intConverter)
+
+	type TestRecord struct {
+		A any `avro:"a"`
+	}
+
+	val := TestRecord{
+		A: float64(27),
+	}
+	err = enc.Encode(val)
+
+	require.NoError(t, err)
+	assert.Equal(t, []byte{0x36}, buf.Bytes())
+}
+
+func TestEncoderTypeConverter_StructRecordNullable(t *testing.T) {
+	defer ConfigTeardown()
+
+	schema := `{"type":"record", "name":"test", "fields":[{"name":"a", "type":["null","int"]}]}`
+	buf := &bytes.Buffer{}
+	enc, err := avro.NewEncoder(schema, buf)
+	require.NoError(t, err)
+
+	avro.RegisterTypeConverters(unionConverter)
+
+	type TestRecord struct {
+		A any `avro:"a"`
+	}
+
+	val := TestRecord{
+		A: float64(27),
+	}
+	err = enc.Encode(val)
+
+	require.NoError(t, err)
+	assert.Equal(t, []byte{0x02, 0x36}, buf.Bytes())
+}
+
+func TestEncoderTypeConverter_StructRecordUnionResolved(t *testing.T) {
+	defer ConfigTeardown()
+
+	schema := `{"type":"record", "name":"test", "fields":[{"name":"a", "type":["string","int"]}]}`
+	buf := &bytes.Buffer{}
+	enc, err := avro.NewEncoder(schema, buf)
+	require.NoError(t, err)
+
+	avro.RegisterTypeConverters(unionConverter)
+
+	type TestRecord struct {
+		A any `avro:"a"`
+	}
+
+	val := TestRecord{
+		A: float64(27),
+	}
+	err = enc.Encode(val)
+
+	require.NoError(t, err)
+	assert.Equal(t, []byte{0x00, 0x04, 0x32, 0x37}, buf.Bytes())
+}
+
+func TestEncoderTypeConverter_StructRecordFixedDecimal(t *testing.T) {
+	defer ConfigTeardown()
+
+	schema := `{"type":"record", "name":"test", "fields":[{"name":"a", "type": {"type":"fixed", "name":"fixed_decimal", "size":6, "logicalType":"decimal", "precision":4, "scale":2}}]}`
+	buf := &bytes.Buffer{}
+	enc, err := avro.NewEncoder(schema, buf)
+	require.NoError(t, err)
+
+	avro.RegisterTypeConverters(fixedDecimalConverter)
+
+	type TestRecord struct {
+		A any `avro:"a"`
+	}
+
+	val := TestRecord{
+		A: "346.8",
+	}
+	err = enc.Encode(val)
+
+	require.NoError(t, err)
+	assert.Equal(t, []byte{0x00, 0x00, 0x00, 0x00, 0x87, 0x78}, buf.Bytes())
+}
+
+func TestEncoderTypeConverter_UnionInterfaceUnregisteredArray(t *testing.T) {
+	defer ConfigTeardown()
+
+	schema := `["int", {"type":"array", "items":"int"}]`
+	buf := &bytes.Buffer{}
+	enc, err := avro.NewEncoder(schema, buf)
+	require.NoError(t, err)
+
+	avro.RegisterTypeConverters(intConverter)
+
+	var val any = map[string]any{
+		"array": []any{
+			float32(27),
+			float64(28),
+		},
+	}
+	err = enc.Encode(val)
+
+	require.NoError(t, err)
+	assert.Equal(t, []byte{0x02, 0x03, 0x04, 0x36, 0x38, 0x00}, buf.Bytes())
+}
+
+func TestEncoderTypeConverter_NotSet(t *testing.T) {
+	defer ConfigTeardown()
+
+	schema := `{"type":"array", "items":"int"}`
+	buf := &bytes.Buffer{}
+	enc, err := avro.NewEncoder(schema, buf)
+	require.NoError(t, err)
+
+	avro.RegisterTypeConverters(nonConverter(avro.Int))
+
+	val := []any{
+		27,
+		28,
+	}
+	err = enc.Encode(val)
+
+	require.NoError(t, err)
+	assert.Equal(t, []byte{0x03, 0x04, 0x36, 0x38, 0x0}, buf.Bytes())
+}
+
+func TestEncoderTypeConverter_Error(t *testing.T) {
+	defer ConfigTeardown()
+
+	schema := `{"type":"array", "items":"int"}`
+	buf := &bytes.Buffer{}
+	enc, err := avro.NewEncoder(schema, buf)
+	require.NoError(t, err)
+
+	testError := errors.New("test error")
+	avro.RegisterTypeConverters(errorConverter(avro.Int, testError))
+
+	val := []any{
+		float32(27.1),
+	}
+	err = enc.Encode(val)
+
+	assert.ErrorIs(t, err, testError)
+	assert.Empty(t, buf.Bytes())
+}
+
+func TestEncoderTypeConverter_ErrorMapUnion(t *testing.T) {
+	defer ConfigTeardown()
+
+	schema := `{"type":"record", "name":"test", "fields":[{"name":"a", "type":["null","int"]}]}`
+	buf := &bytes.Buffer{}
+	enc, err := avro.NewEncoder(schema, buf)
+	require.NoError(t, err)
+
+	testError := errors.New("test error")
+	avro.RegisterTypeConverters(avro.TypeConversionFuncs{
+		AvroType: avro.Union,
+		EncoderTypeConversion: func(in any, schema avro.Schema) (any, error) {
+			if _, ok := in.(int); ok {
+				return nil, testError
+			}
+			return in, nil
+		},
+	})
+
+	val := map[string]any{
+		"a": map[string]any{"int": 27},
+	}
+	err = enc.Encode(val)
+
+	assert.ErrorIs(t, err, testError)
+	assert.Empty(t, buf.Bytes())
+}
diff --git a/typeconverter_test.go b/typeconverter_test.go
index ca2b44f..d950fd9 100644
--- a/typeconverter_test.go
+++ b/typeconverter_test.go
@@ -1,21 +1,16 @@
 package avro_test
 
 import (
-	"bytes"
-	"errors"
 	"fmt"
 	"math/big"
-	"testing"
 
 	"github.com/hamba/avro/v2"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
 )
 
 var (
 	boolConverter = avro.TypeConversionFuncs{
 		AvroType: avro.Boolean,
-		DecoderTypeConversion: func(in any) (any, error) {
+		DecoderTypeConversion: func(in any, _ avro.Schema) (any, error) {
 			b := in.(bool)
 			if b {
 				return "yes", nil
@@ -25,29 +20,36 @@ var (
 		},
 	}
 
-	intConverter = avro.TypeConversionFuncs{
-		AvroType: avro.Int,
-		EncoderTypeConversion: func(in any) (any, error) {
-			switch v := in.(type) {
-			case float32:
-				if float32(int(v)) != v {
-					return 0, fmt.Errorf("%v is not an integer", in)
-				}
-				return int(v), nil
-			case float64:
-				if float64(int(v)) != v {
-					return 0, fmt.Errorf("%v is not an integer", in)
-				}
-				return int(v), nil
+	floatToAvroInt = func(in any, _ avro.Schema) (any, error) {
+		switch v := in.(type) {
+		case float32:
+			if float32(int(v)) != v {
+				return 0, fmt.Errorf("%v is not an integer", in)
 			}
-			return in, nil
-		},
+			return int(v), nil
+		case float64:
+			if float64(int(v)) != v {
+				return 0, fmt.Errorf("%v is not an integer", in)
+			}
+			return int(v), nil
+		case *float64:
+			if float64(int(*v)) != *v {
+				return 0, fmt.Errorf("%v is not an integer", *v)
+			}
+			return int(*v), nil
+		}
+		return in, nil
+	}
+
+	intConverter = avro.TypeConversionFuncs{
+		AvroType:              avro.Int,
+		EncoderTypeConversion: floatToAvroInt,
 	}
 
 	fixedDecimalConverter = avro.TypeConversionFuncs{
 		AvroType:        avro.Fixed,
 		AvroLogicalType: avro.Decimal,
-		EncoderTypeConversion: func(in any) (any, error) {
+		EncoderTypeConversion: func(in any, _ avro.Schema) (any, error) {
 			switch v := in.(type) {
 			case string:
 				val, _ := new(big.Rat).SetString(v)
@@ -55,12 +57,41 @@ var (
 			}
 			return in, nil
 		},
-		DecoderTypeConversion: func(in any) (any, error) {
+		DecoderTypeConversion: func(in any, _ avro.Schema) (any, error) {
 			r := in.(*big.Rat)
 			f, _ := r.Float64()
 			return f, nil
 		},
 	}
+
+	unionConverter = avro.TypeConversionFuncs{
+		AvroType: avro.Union,
+		EncoderTypeConversion: func(in any, schema avro.Schema) (any, error) {
+			union := schema.(*avro.UnionSchema)
+			if union.Contains(avro.String) {
+				return fmt.Sprintf("%v", in), nil
+			}
+			if union.Contains(avro.Int) {
+				return floatToAvroInt(in, schema)
+			}
+			return in, nil
+		},
+		DecoderTypeConversion: func(in any, _ avro.Schema) (any, error) {
+			switch v := in.(type) {
+			case bool:
+				if v {
+					return "yes", nil
+				} else {
+					return "no", nil
+				}
+			case *big.Rat:
+				f, _ := v.Float64()
+				return f, nil
+			}
+
+			return in, nil
+		},
+	}
 )
 
 func nonConverter(typ avro.Type) avro.TypeConversionFuncs {
@@ -72,207 +103,11 @@ func nonConverter(typ avro.Type) avro.TypeConversionFuncs {
 func errorConverter(typ avro.Type, err error) avro.TypeConversionFuncs {
 	return avro.TypeConversionFuncs{
 		AvroType: typ,
-		EncoderTypeConversion: func(in any) (any, error) {
+		EncoderTypeConversion: func(in any, _ avro.Schema) (any, error) {
 			return nil, err
 		},
-		DecoderTypeConversion: func(in any) (any, error) {
+		DecoderTypeConversion: func(in any, _ avro.Schema) (any, error) {
 			return nil, err
 		},
 	}
 }
-
-func TestEncoderTypeConverter_Array(t *testing.T) {
-	defer ConfigTeardown()
-
-	schema := `{"type":"array", "items":"int"}`
-	buf := bytes.NewBuffer([]byte{})
-	enc, err := avro.NewEncoder(schema, buf)
-	require.NoError(t, err)
-
-	avro.RegisterTypeConverters(intConverter)
-
-	val := []any{
-		float32(27),
-		float64(28),
-	}
-	err = enc.Encode(val)
-
-	require.NoError(t, err)
-	assert.Equal(t, []byte{0x03, 0x04, 0x36, 0x38, 0x0}, buf.Bytes())
-}
-
-func TestEncoderTypeConverter_RecordMap(t *testing.T) {
-	defer ConfigTeardown()
-
-	schema := `{"type":"record", "name":"test", "fields":[{"name":"a", "type":"int"},{"name":"b", "type": {"type":"fixed", "name":"fixed", "size":6, "logicalType":"decimal", "precision":4, "scale":2}}]}`
-	buf := &bytes.Buffer{}
-	enc, err := avro.NewEncoder(schema, buf)
-	require.NoError(t, err)
-
-	avro.RegisterTypeConverters(intConverter, fixedDecimalConverter)
-
-	val := map[string]any{
-		"a": float64(27),
-		"b": "346.8",
-	}
-	err = enc.Encode(val)
-
-	require.NoError(t, err)
-	assert.Equal(t, []byte{0x36, 0x00, 0x00, 0x00, 0x00, 0x87, 0x78}, buf.Bytes())
-}
-
-func TestEncoderTypeConverter_RecordStruct(t *testing.T) {
-	defer ConfigTeardown()
-
-	schema := `{"type":"record", "name":"test", "fields":[{"name":"a", "type":"int"},{"name":"b", "type": {"type":"fixed", "name":"fixed", "size":6, "logicalType":"decimal", "precision":4, "scale":2}}]}`
-	buf := &bytes.Buffer{}
-	enc, err := avro.NewEncoder(schema, buf)
-	require.NoError(t, err)
-
-	avro.RegisterTypeConverters(intConverter, fixedDecimalConverter)
-
-	type TestRecord struct {
-		A any `avro:"a"`
-		B any `avro:"b"`
-	}
-
-	val := TestRecord{
-		A: float64(27),
-		B: "346.8",
-	}
-	err = enc.Encode(val)
-
-	require.NoError(t, err)
-	assert.Equal(t, []byte{0x36, 0x00, 0x00, 0x00, 0x00, 0x87, 0x78}, buf.Bytes())
-}
-
-func TestEncoderTypeConverter_UnionInterfaceUnregisteredArray(t *testing.T) {
-	defer ConfigTeardown()
-
-	schema := `["int", {"type":"array", "items":"int"}]`
-	buf := bytes.NewBuffer([]byte{})
-	enc, err := avro.NewEncoder(schema, buf)
-	require.NoError(t, err)
-
-	avro.RegisterTypeConverters(intConverter)
-
-	var val any = map[string]any{
-		"array": []any{
-			float32(27),
-			float64(28),
-		},
-	}
-	err = enc.Encode(val)
-
-	require.NoError(t, err)
-	assert.Equal(t, []byte{0x02, 0x03, 0x04, 0x36, 0x38, 0x00}, buf.Bytes())
-}
-
-func TestEncoderTypeConverter_NotSet(t *testing.T) {
-	defer ConfigTeardown()
-
-	schema := `{"type":"array", "items":"int"}`
-	buf := bytes.NewBuffer([]byte{})
-	enc, err := avro.NewEncoder(schema, buf)
-	require.NoError(t, err)
-
-	avro.RegisterTypeConverters(nonConverter(avro.Int))
-
-	val := []any{
-		27,
-		28,
-	}
-	err = enc.Encode(val)
-
-	require.NoError(t, err)
-	assert.Equal(t, []byte{0x03, 0x04, 0x36, 0x38, 0x0}, buf.Bytes())
-}
-
-func TestEncoderTypeConverter_Error(t *testing.T) {
-	defer ConfigTeardown()
-
-	schema := `{"type":"array", "items":"int"}`
-	buf := bytes.NewBuffer([]byte{})
-	enc, err := avro.NewEncoder(schema, buf)
-	require.NoError(t, err)
-
-	testError := errors.New("test error")
-	avro.RegisterTypeConverters(errorConverter(avro.Int, testError))
-
-	val := []any{
-		float32(27.1),
-	}
-	err = enc.Encode(val)
-
-	assert.ErrorIs(t, err, testError)
-	assert.Empty(t, buf.Bytes())
-}
-
-func TestDecoderTypeConverter_Single(t *testing.T) {
-	defer ConfigTeardown()
-
-	data := []byte{0x01}
-	schema := `{"type":"boolean"}`
-	dec, err := avro.NewDecoder(schema, bytes.NewReader(data))
-	require.NoError(t, err)
-
-	avro.RegisterTypeConverters(boolConverter)
-
-	var b any
-	err = dec.Decode(&b)
-
-	require.NoError(t, err)
-	assert.Equal(t, "yes", b)
-}
-
-func TestDecoderTypeConverter_FixedRat(t *testing.T) {
-	defer ConfigTeardown()
-
-	data := []byte{0x00, 0x00, 0x00, 0x00, 0x87, 0x78}
-	schema := `{"type":"fixed", "name": "test", "size": 6,"logicalType":"decimal","precision":4,"scale":2}`
-	dec, err := avro.NewDecoder(schema, bytes.NewReader(data))
-	require.NoError(t, err)
-
-	avro.RegisterTypeConverters(fixedDecimalConverter)
-
-	var got any
-	err = dec.Decode(&got)
-
-	require.NoError(t, err)
-	assert.Equal(t, float64(346.8), got)
-}
-
-func TestDecoderTypeConverter_NotSet(t *testing.T) {
-	defer ConfigTeardown()
-
-	data := []byte{0x01}
-	schema := `{"type":"boolean"}`
-	dec, err := avro.NewDecoder(schema, bytes.NewReader(data))
-	require.NoError(t, err)
-
-	avro.RegisterTypeConverters(nonConverter(avro.Boolean))
-
-	var b any
-	err = dec.Decode(&b)
-
-	require.NoError(t, err)
-	assert.Equal(t, true, b)
-}
-
-func TestDecoderTypeConverter_Error(t *testing.T) {
-	defer ConfigTeardown()
-
-	data := []byte{0x36}
-	schema := `{"type":"int"}`
-	dec, err := avro.NewDecoder(schema, bytes.NewReader(data))
-	require.NoError(t, err)
-
-	testError := errors.New("test error")
-	avro.RegisterTypeConverters(errorConverter(avro.Int, testError))
-
-	var got any
-	err = dec.Decode(&got)
-
-	assert.ErrorIs(t, err, testError)
-	assert.Nil(t, got)
-}