Repository: fxamacker/cbor
Pull request #672 (2025-06-11 13:49:51)

Add options to support TextMarshaler and TextUnmarshaler
<!--
Thank you for your interest in contributing to fxamacker/cbor!
-->

### Description

This adds TextMarshaler and TextUnmarshaler options to EncOptions and DecOptions, respectively. They're essentially derivative of the existing options for BinaryMarshaler and BinaryUnmarshaler.

Enabling this improves drop-in compatibility with `encoding/json`, which similarly marshals non-`json.Marshaler` `TextMarshaler`s to strings and unmarshalers non-`json.Unmarshaler` `TextUnmarshaler`s from strings.

<!-- For code contributions, please complete all the items below this line. -->
<!-- For documentation-only contributions, please delete everything below this line. -->

#### PR Was Proposed and Welcomed in Currently Open Issue

- [x] This PR was proposed and welcomed by maintainer(s) in issue #675
- [x] Closes or Updates Issue #675 

#### Checklist (for code PR only, ignore for docs PR)

- [x] Include unit tests that cover the new code
- [x] Pass all unit tests 
- [x] Pass all lint checks in CI (goimports, gosec, staticcheck, etc.)
- [x] Sign each commit with your real name and email.  
      Last line of each commit message should be in this format:  
      Signed-off-by: Firstname Lastname <firstname.lastname@example.com>
- [x] Certify the Developer's Certificate of Origin 1.1
      (see next section).

#### Certify the Developer's Certificate of Origin 1.1

- [x] By marking this item as completed, I certify 
      the Developer Certificate of Origin 1.1.

```
Developer Certificate of Origin
Version 1.1

Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
660 York Street, Suite 102,
San Francisco, CA 94110 USA

Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.

Developer's Certificate of Origin 1.1

By making a contribution to this project, I certify that:

(a) The contribution was created in whole or in part by me and I
    have the right to submit it under the open source license
    indicated in the file; or

(b) The contribution is based upon previous work that, to the best
    of my knowledge, is covered under an appropriate open source
    license and I have the right under that license to submit that
    work with modifications, whether created in whole or in part
    by me, under the same open source license (unless I am
    permitted to submit under a different license), as indicated
    in the file; or

(c) The contribution was provided directly to me by some other
    person who certified (a), (b) or (c) and I have not modified
    it.

(d) I understand and agree that this project and the contribution
    are public and that a record of the contribution (including all
    personal information I submit with it, including my sign-off) is
    maintained indefinitely and may be redistributed consistent with
    this project or the open source license(s) involved.
```



benluddy: Oops, missed some test coverage. I'll take care of that.

Patch:
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 79fbb06..7936d14 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -35,7 +35,7 @@ jobs:
 
     # Initializes the CodeQL tools for scanning.
     - name: Initialize CodeQL
-      uses: github/codeql-action/init@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3.29.0
+      uses: github/codeql-action/init@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
       with:
         languages: ${{ matrix.language }}
 
@@ -44,7 +44,7 @@ jobs:
     # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java).
     # If this step fails, then you should remove it and run the build manually (see below)
     - name: Autobuild
-      uses: github/codeql-action/autobuild@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3.29.0
+      uses: github/codeql-action/autobuild@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
 
     - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3.29.0
+      uses: github/codeql-action/analyze@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
diff --git a/README.md b/README.md
index 89faa69..ed28371 100644
--- a/README.md
+++ b/README.md
@@ -681,7 +681,7 @@ because RFC 8949 treats CBOR data item with remaining bytes as malformed.
 Other useful functions: 
 - `Diagnose`, `DiagnoseFirst` produce human-readable [Extended Diagnostic Notation](https://www.rfc-editor.org/rfc/rfc8610.html#appendix-G) from CBOR data.
 - `UnmarshalFirst` decodes first CBOR data item and return any remaining bytes.
-- `Wellformed` returns true if the CBOR data item is well-formed.
+- `Wellformed` returns true if the the CBOR data item is well-formed.
 
 Interfaces identical or comparable to Go `encoding` packages include:  
 `Marshaler`, `Unmarshaler`, `BinaryMarshaler`, and `BinaryUnmarshaler`.
diff --git a/decode.go b/decode.go
index 07f7f83..83bd9e5 100644
--- a/decode.go
+++ b/decode.go
@@ -36,7 +36,7 @@ import (
 //
 // To unmarshal CBOR into a pointer, Unmarshal sets the pointer to nil
 // if CBOR data is null (0xf6) or undefined (0xf7).  Otherwise, Unmarshal
-// unmarshalls CBOR into the value pointed to by the pointer.  If the
+// unmarshals CBOR into the value pointed to by the pointer.  If the
 // pointer is nil, Unmarshal creates a new value for it to point to.
 //
 // To unmarshal CBOR into an empty interface value, Unmarshal uses the
@@ -749,6 +749,25 @@ func (bum BinaryUnmarshalerMode) valid() bool {
 	return bum >= 0 && bum < maxBinaryUnmarshalerMode
 }
 
+// TextUnmarshalerMode specifies how to decode into types that implement
+// encoding.TextUnmarshaler.
+type TextUnmarshalerMode int
+
+const (
+	// TextUnmarshalerNone does not recognize TextUnmarshaler implementations during decode.
+	TextUnmarshalerNone TextUnmarshalerMode = iota
+
+	// TextUnmarshalerTextString will invoke UnmarshalText on the contents of a CBOR text
+	// string when decoding into a value that implements TextUnmarshaler.
+	TextUnmarshalerTextString
+
+	maxTextUnmarshalerMode
+)
+
+func (tum TextUnmarshalerMode) valid() bool {
+	return tum >= 0 && tum < maxTextUnmarshalerMode
+}
+
 // DecOptions specifies decoding options.
 type DecOptions struct {
 	// DupMapKey specifies whether to enforce duplicate map key.
@@ -883,6 +902,10 @@ type DecOptions struct {
 	// BinaryUnmarshaler specifies how to decode into types that implement
 	// encoding.BinaryUnmarshaler.
 	BinaryUnmarshaler BinaryUnmarshalerMode
+
+	// TextUnmarshaler specifies how to decode into types that implement
+	// encoding.TextUnmarshaler.
+	TextUnmarshaler TextUnmarshalerMode
 }
 
 // DecMode returns DecMode with immutable options and no tags (safe for concurrency).
@@ -1095,6 +1118,10 @@ func (opts DecOptions) decMode() (*decMode, error) { //nolint:gocritic // ignore
 		return nil, errors.New("cbor: invalid BinaryUnmarshaler " + strconv.Itoa(int(opts.BinaryUnmarshaler)))
 	}
 
+	if !opts.TextUnmarshaler.valid() {
+		return nil, errors.New("cbor: invalid TextUnmarshaler " + strconv.Itoa(int(opts.TextUnmarshaler)))
+	}
+
 	dm := decMode{
 		dupMapKey:                opts.DupMapKey,
 		timeTag:                  opts.TimeTag,
@@ -1122,6 +1149,7 @@ func (opts DecOptions) decMode() (*decMode, error) { //nolint:gocritic // ignore
 		byteStringExpectedFormat: opts.ByteStringExpectedFormat,
 		bignumTag:                opts.BignumTag,
 		binaryUnmarshaler:        opts.BinaryUnmarshaler,
+		textUnmarshaler:          opts.TextUnmarshaler,
 	}
 
 	return &dm, nil
@@ -1201,6 +1229,7 @@ type decMode struct {
 	byteStringExpectedFormat ByteStringExpectedFormatMode
 	bignumTag                BignumTagMode
 	binaryUnmarshaler        BinaryUnmarshalerMode
+	textUnmarshaler          TextUnmarshalerMode
 }
 
 var defaultDecMode, _ = DecOptions{}.decMode()
@@ -1241,6 +1270,7 @@ func (dm *decMode) DecOptions() DecOptions {
 		ByteStringExpectedFormat: dm.byteStringExpectedFormat,
 		BignumTag:                dm.bignumTag,
 		BinaryUnmarshaler:        dm.binaryUnmarshaler,
+		TextUnmarshaler:          dm.textUnmarshaler,
 	}
 }
 
@@ -1530,7 +1560,7 @@ func (d *decoder) parseToValue(v reflect.Value, tInfo *typeInfo) error { //nolin
 		if err != nil {
 			return err
 		}
-		return fillTextString(t, b, v)
+		return fillTextString(t, b, v, d.dm.textUnmarshaler)
 
 	case cborTypePrimitives:
 		_, ai, val := d.getHead()
@@ -2995,6 +3025,7 @@ var (
 	typeUnmarshaler           = reflect.TypeOf((*Unmarshaler)(nil)).Elem()
 	typeUnexportedUnmarshaler = reflect.TypeOf((*unmarshaler)(nil)).Elem()
 	typeBinaryUnmarshaler     = reflect.TypeOf((*encoding.BinaryUnmarshaler)(nil)).Elem()
+	typeTextUnmarshaler       = reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem()
 	typeString                = reflect.TypeOf("")
 	typeByteSlice             = reflect.TypeOf([]byte(nil))
 )
@@ -3147,11 +3178,28 @@ func fillByteString(t cborType, val []byte, shared bool, v reflect.Value, bsts B
 	return &UnmarshalTypeError{CBORType: t.String(), GoType: v.Type().String()}
 }
 
-func fillTextString(t cborType, val []byte, v reflect.Value) error {
+func fillTextString(t cborType, val []byte, v reflect.Value, tum TextUnmarshalerMode) error {
+	// Check if the value implements TextUnmarshaler and the mode allows it
+	if tum == TextUnmarshalerTextString && reflect.PointerTo(v.Type()).Implements(typeTextUnmarshaler) {
+		if v.CanAddr() {
+			v = v.Addr()
+			if u, ok := v.Interface().(encoding.TextUnmarshaler); ok {
+				// The contract of TextUnmarshaler forbids retaining the input
+				// bytes, so no copying is required even if val is shared.
+				if err := u.UnmarshalText(val); err != nil {
+					return fmt.Errorf("cbor: cannot unmarshal text for %s: %w", v.Type(), err)
+				}
+				return nil
+			}
+		}
+		return errors.New("cbor: cannot set new value for " + v.Type().String())
+	}
+
 	if v.Kind() == reflect.String {
 		v.SetString(string(val))
 		return nil
 	}
+
 	return &UnmarshalTypeError{CBORType: t.String(), GoType: v.Type().String()}
 }
 
diff --git a/encode.go b/encode.go
index 0cc3b11..f2433f7 100644
--- a/encode.go
+++ b/encode.go
@@ -319,7 +319,7 @@ const (
 	TimeUnixMicro
 
 	// TimeUnixDynamic causes time.Time to encode to a CBOR time (tag 1) with either an integer content or
-	// a floating point content, depending on the content's value.  This option is equivalent to dynamically
+	// or a floating point content, depending on the content's value.  This option is equivalent to dynamically
 	// choosing TimeUnix if time.Time doesn't have fractional seconds, and using TimeUnixMicro if time.Time
 	// has fractional seconds.
 	TimeUnixDynamic
@@ -510,6 +510,24 @@ func (bmm BinaryMarshalerMode) valid() bool {
 	return bmm >= 0 && bmm < maxBinaryMarshalerMode
 }
 
+// TextMarshalerMode specifies how to encode types that implement encoding.TextMarshaler.
+type TextMarshalerMode int
+
+const (
+	// TextMarshalerNone does not recognize TextMarshaler implementations during encode.
+	// This is the default behavior.
+	TextMarshalerNone TextMarshalerMode = iota
+
+	// TextMarshalerTextString encodes the output of MarshalText to a CBOR text string.
+	TextMarshalerTextString
+
+	maxTextMarshalerMode
+)
+
+func (tmm TextMarshalerMode) valid() bool {
+	return tmm >= 0 && tmm < maxTextMarshalerMode
+}
+
 // EncOptions specifies encoding options.
 type EncOptions struct {
 	// Sort specifies sorting order.
@@ -567,6 +585,9 @@ type EncOptions struct {
 
 	// BinaryMarshaler specifies how to encode types that implement encoding.BinaryMarshaler.
 	BinaryMarshaler BinaryMarshalerMode
+
+	// TextMarshaler specifies how to encode types that implement encoding.TextMarshaler.
+	TextMarshaler TextMarshalerMode
 }
 
 // CanonicalEncOptions returns EncOptions for "Canonical CBOR" encoding,
@@ -777,6 +798,9 @@ func (opts EncOptions) encMode() (*encMode, error) { //nolint:gocritic // ignore
 	if !opts.BinaryMarshaler.valid() {
 		return nil, errors.New("cbor: invalid BinaryMarshaler " + strconv.Itoa(int(opts.BinaryMarshaler)))
 	}
+	if !opts.TextMarshaler.valid() {
+		return nil, errors.New("cbor: invalid TextMarshaler " + strconv.Itoa(int(opts.TextMarshaler)))
+	}
 	em := encMode{
 		sort:                      opts.Sort,
 		shortestFloat:             opts.ShortestFloat,
@@ -796,6 +820,7 @@ func (opts EncOptions) encMode() (*encMode, error) { //nolint:gocritic // ignore
 		byteSliceLaterEncodingTag: byteSliceLaterEncodingTag,
 		byteArray:                 opts.ByteArray,
 		binaryMarshaler:           opts.BinaryMarshaler,
+		textMarshaler:             opts.TextMarshaler,
 	}
 	return &em, nil
 }
@@ -841,6 +866,7 @@ type encMode struct {
 	byteSliceLaterEncodingTag uint64
 	byteArray                 ByteArrayMode
 	binaryMarshaler           BinaryMarshalerMode
+	textMarshaler             TextMarshalerMode
 }
 
 var defaultEncMode, _ = EncOptions{}.encMode()
@@ -933,6 +959,7 @@ func (em *encMode) EncOptions() EncOptions {
 		ByteSliceLaterFormat: em.byteSliceLaterFormat,
 		ByteArray:            em.byteArray,
 		BinaryMarshaler:      em.binaryMarshaler,
+		TextMarshaler:        em.textMarshaler,
 	}
 }
 
@@ -1704,6 +1731,54 @@ func (bme binaryMarshalerEncoder) isEmpty(em *encMode, v reflect.Value) (bool, e
 	return len(data) == 0, nil
 }
 
+type textMarshalerEncoder struct {
+	alternateEncode  encodeFunc
+	alternateIsEmpty isEmptyFunc
+}
+
+func (tme textMarshalerEncoder) encode(e *bytes.Buffer, em *encMode, v reflect.Value) error {
+	if em.textMarshaler == TextMarshalerNone {
+		return tme.alternateEncode(e, em, v)
+	}
+
+	vt := v.Type()
+	m, ok := v.Interface().(encoding.TextMarshaler)
+	if !ok {
+		pv := reflect.New(vt)
+		pv.Elem().Set(v)
+		m = pv.Interface().(encoding.TextMarshaler)
+	}
+	data, err := m.MarshalText()
+	if err != nil {
+		return fmt.Errorf("cbor: cannot marshal text for %s: %w", vt, err)
+	}
+	if b := em.encTagBytes(vt); b != nil {
+		e.Write(b)
+	}
+
+	encodeHead(e, byte(cborTypeTextString), uint64(len(data)))
+	e.Write(data)
+	return nil
+}
+
+func (tme textMarshalerEncoder) isEmpty(em *encMode, v reflect.Value) (bool, error) {
+	if em.textMarshaler == TextMarshalerNone {
+		return tme.alternateIsEmpty(em, v)
+	}
+
+	m, ok := v.Interface().(encoding.TextMarshaler)
+	if !ok {
+		pv := reflect.New(v.Type())
+		pv.Elem().Set(v)
+		m = pv.Interface().(encoding.TextMarshaler)
+	}
+	data, err := m.MarshalText()
+	if err != nil {
+		return false, fmt.Errorf("cbor: cannot marshal text for %s: %w", v.Type(), err)
+	}
+	return len(data) == 0, nil
+}
+
 func encodeMarshalerType(e *bytes.Buffer, em *encMode, v reflect.Value) error {
 	if em.tagsMd == TagsForbidden && v.Type() == typeRawTag {
 		return errors.New("cbor: cannot encode cbor.RawTag when TagsMd is TagsForbidden")
@@ -1810,6 +1885,7 @@ func encodeHead(e *bytes.Buffer, t byte, n uint64) int {
 var (
 	typeMarshaler       = reflect.TypeOf((*Marshaler)(nil)).Elem()
 	typeBinaryMarshaler = reflect.TypeOf((*encoding.BinaryMarshaler)(nil)).Elem()
+	typeTextMarshaler   = reflect.TypeOf((*encoding.TextMarshaler)(nil)).Elem()
 	typeRawMessage      = reflect.TypeOf(RawMessage(nil))
 	typeByteString      = reflect.TypeOf(ByteString(""))
 )
@@ -1852,6 +1928,17 @@ func getEncodeFuncInternal(t reflect.Type) (ef encodeFunc, ief isEmptyFunc, izf
 			ief = bme.isEmpty
 		}()
 	}
+	if reflect.PointerTo(t).Implements(typeTextMarshaler) {
+		defer func() {
+			// capture encoding method used for modes that disable TextMarshaler
+			tme := textMarshalerEncoder{
+				alternateEncode:  ef,
+				alternateIsEmpty: ief,
+			}
+			ef = tme.encode
+			ief = tme.isEmpty
+		}()
+	}
 	switch k {
 	case reflect.Bool:
 		return encodeBool, isEmptyBool, getIsZeroFunc(t)
diff --git a/tag.go b/tag.go
index d418011..bd8b773 100644
--- a/tag.go
+++ b/tag.go
@@ -10,7 +10,7 @@ import (
 	"sync"
 )
 
-// Tag represents a tagged data item (CBOR major type 6), comprising a tag number and the unmarshalled tag content.
+// Tag represents a tagged data item (CBOR major type 6), comprising a tag number and the unmarshaled tag content.
 // NOTE: The same encoding and decoding options that apply to untagged CBOR data items also applies to tag content
 // during encoding and decoding.
 type Tag struct {


Test patch:
diff --git a/bench_test.go b/bench_test.go
index 0f3e897..ad7c465 100644
--- a/bench_test.go
+++ b/bench_test.go
@@ -45,7 +45,7 @@ type coseKey struct {
 	IV        []byte     `cbor:"5,keyasint,omitempty"`
 	CrvOrNOrK RawMessage `cbor:"-1,keyasint,omitempty"` // K for symmetric keys, Crv for elliptic curve keys, N for RSA modulus
 	XOrE      RawMessage `cbor:"-2,keyasint,omitempty"` // X for curve x-coordinate, E for RSA public exponent
-	Y         RawMessage `cbor:"-3,keyasint,omitempty"` // Y for curve y-coordinate
+	Y         RawMessage `cbor:"-3,keyasint,omitempty"` // Y for curve y-cooridate
 	D         []byte     `cbor:"-4,keyasint,omitempty"`
 }
 
diff --git a/decode_test.go b/decode_test.go
index aa21dc2..9df55d5 100644
--- a/decode_test.go
+++ b/decode_test.go
@@ -4925,6 +4925,7 @@ func TestDecOptions(t *testing.T) {
 		ByteStringExpectedFormat: ByteStringExpectedBase64URL,
 		BignumTag:                BignumTagForbidden,
 		BinaryUnmarshaler:        BinaryUnmarshalerNone,
+		TextUnmarshaler:          TextUnmarshalerTextString,
 	}
 	ov := reflect.ValueOf(opts1)
 	for i := 0; i < ov.NumField(); i++ {
@@ -10132,3 +10133,105 @@ func TestBignumTagMode(t *testing.T) {
 		})
 	}
 }
+
+func TestDecModeInvalidTextUnmarshaler(t *testing.T) {
+	for _, tc := range []struct {
+		name         string
+		opts         DecOptions
+		wantErrorMsg string
+	}{
+		{
+			name:         "below range of valid modes",
+			opts:         DecOptions{TextUnmarshaler: -1},
+			wantErrorMsg: "cbor: invalid TextUnmarshaler -1",
+		},
+		{
+			name:         "above range of valid modes",
+			opts:         DecOptions{TextUnmarshaler: 101},
+			wantErrorMsg: "cbor: invalid TextUnmarshaler 101",
+		},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			_, err := tc.opts.DecMode()
+			if err == nil {
+				t.Errorf("DecMode() didn't return an error")
+			} else if err.Error() != tc.wantErrorMsg {
+				t.Errorf("DecMode() returned error %q, want %q", err.Error(), tc.wantErrorMsg)
+			}
+		})
+	}
+}
+
+type testTextUnmarshaler string
+
+func (tu *testTextUnmarshaler) UnmarshalText(_ []byte) error {
+	*tu = "UnmarshalText"
+	return nil
+}
+
+func TestTextUnmarshalerMode(t *testing.T) {
+	for _, tc := range []struct {
+		name string
+		opts DecOptions
+		in   []byte
+		want any
+	}{
+		{
+			name: "UnmarshalText is not called by default",
+			opts: DecOptions{},
+			in:   []byte("\x65hello"), // "hello"
+			want: testTextUnmarshaler("hello"),
+		},
+		{
+			name: "UnmarshalText is called with TextUnmarshalerTextString",
+			opts: DecOptions{TextUnmarshaler: TextUnmarshalerTextString},
+			in:   []byte("\x65hello"), // "hello"
+			want: testTextUnmarshaler("UnmarshalText"),
+		},
+		{
+			name: "default text string unmarshaling behavior is used with TextUnmarshalerNone",
+			opts: DecOptions{TextUnmarshaler: TextUnmarshalerNone},
+			in:   []byte("\x65hello"), // "hello"
+			want: testTextUnmarshaler("hello"),
+		},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			dm, err := tc.opts.DecMode()
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			gotrv := reflect.New(reflect.TypeOf(tc.want))
+			if err := dm.Unmarshal(tc.in, gotrv.Interface()); err != nil {
+				t.Fatal(err)
+			}
+
+			got := gotrv.Elem().Interface()
+			if !reflect.DeepEqual(tc.want, got) {
+				t.Errorf("want: %v, got: %v", tc.want, got)
+			}
+		})
+	}
+}
+
+type errorTextUnmarshaler struct{}
+
+func (u *errorTextUnmarshaler) UnmarshalText([]byte) error {
+	return errors.New("test")
+}
+
+func TestTextUnmarshalerModeError(t *testing.T) {
+	dec, err := DecOptions{TextUnmarshaler: TextUnmarshalerTextString}.DecMode()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = dec.Unmarshal([]byte{0x61, 'a'}, new(errorTextUnmarshaler))
+	if err == nil {
+		t.Fatal("expected non-nil error")
+	}
+
+	if got, want := err.Error(), "cbor: cannot unmarshal text for *cbor.errorTextUnmarshaler: test"; got != want {
+		t.Errorf("want: %q, got: %q", want, got)
+	}
+}
diff --git a/encode_test.go b/encode_test.go
index e1f34cc..860a7db 100644
--- a/encode_test.go
+++ b/encode_test.go
@@ -6,6 +6,7 @@ package cbor
 import (
 	"bytes"
 	"encoding/binary"
+	"errors"
 	"fmt"
 	"io"
 	"math"
@@ -4520,6 +4521,7 @@ func TestEncOptions(t *testing.T) {
 		ByteSliceLaterFormat: ByteSliceLaterFormatBase16,
 		ByteArray:            ByteArrayToArray,
 		BinaryMarshaler:      BinaryMarshalerNone,
+		TextMarshaler:        TextMarshalerTextString,
 	}
 	ov := reflect.ValueOf(opts1)
 	for i := 0; i < ov.NumField(); i++ {
@@ -4581,7 +4583,7 @@ func TestEncModeStringType(t *testing.T) {
 		wantErrorMsg string
 	}{
 		{
-			name:         "",
+			name:         "invalid mode",
 			opts:         EncOptions{String: -1},
 			wantErrorMsg: "cbor: invalid StringType -1",
 		},
@@ -4604,12 +4606,12 @@ func TestEncModeInvalidFieldNameMode(t *testing.T) {
 		wantErrorMsg string
 	}{
 		{
-			name:         "",
+			name:         "below range of valid modes",
 			opts:         EncOptions{FieldName: -1},
 			wantErrorMsg: "cbor: invalid FieldName -1",
 		},
 		{
-			name:         "",
+			name:         "above range of valid modes",
 			opts:         EncOptions{FieldName: 101},
 			wantErrorMsg: "cbor: invalid FieldName 101",
 		},
@@ -5497,12 +5499,12 @@ func TestEncModeInvalidBinaryMarshalerMode(t *testing.T) {
 		wantErrorMsg string
 	}{
 		{
-			name:         "",
+			name:         "below range of valid modes",
 			opts:         EncOptions{BinaryMarshaler: -1},
 			wantErrorMsg: "cbor: invalid BinaryMarshaler -1",
 		},
 		{
-			name:         "",
+			name:         "above range of valid modes",
 			opts:         EncOptions{BinaryMarshaler: 101},
 			wantErrorMsg: "cbor: invalid BinaryMarshaler 101",
 		},
@@ -5518,6 +5520,34 @@ func TestEncModeInvalidBinaryMarshalerMode(t *testing.T) {
 	}
 }
 
+func TestEncModeInvalidTextMarshalerMode(t *testing.T) {
+	for _, tc := range []struct {
+		name         string
+		opts         EncOptions
+		wantErrorMsg string
+	}{
+		{
+			name:         "below range of valid modes",
+			opts:         EncOptions{TextMarshaler: -1},
+			wantErrorMsg: "cbor: invalid TextMarshaler -1",
+		},
+		{
+			name:         "above range of valid modes",
+			opts:         EncOptions{TextMarshaler: 101},
+			wantErrorMsg: "cbor: invalid TextMarshaler 101",
+		},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			_, err := tc.opts.EncMode()
+			if err == nil {
+				t.Errorf("EncMode() didn't return an error")
+			} else if err.Error() != tc.wantErrorMsg {
+				t.Errorf("EncMode() returned error %q, want %q", err.Error(), tc.wantErrorMsg)
+			}
+		})
+	}
+}
+
 type testBinaryMarshaler struct {
 	StringField  string `cbor:"s"`
 	IntegerField int64  `cbor:"i"`
@@ -5588,3 +5618,139 @@ func TestBinaryMarshalerMode(t *testing.T) {
 		})
 	}
 }
+
+type testTextMarshaler struct {
+	String string `cbor:"s"`
+	Error  error  `cbor:"-"`
+}
+
+func (tm *testTextMarshaler) MarshalText() ([]byte, error) {
+	return []byte(tm.String), tm.Error
+}
+
+func TestTextMarshalerMode(t *testing.T) {
+	testTags := NewTagSet()
+	if err := testTags.Add(TagOptions{EncTag: EncTagRequired}, reflect.TypeOf(testTextMarshaler{}), 9999); err != nil {
+		t.Fatal(err)
+	}
+
+	for _, tc := range []struct {
+		name string
+		opts EncOptions
+		tags TagSet
+		in   any
+		want []byte
+	}{
+		{
+			name: "struct implementing TextMarshaler is encoded to map by default",
+			opts: EncOptions{},
+			in:   testTextMarshaler{String: "z"},
+			want: []byte{0xa1, 0x61, 's', 0x61, 'z'}, // {"s": "z"}
+		},
+		{
+			name: "struct implementing TextMarshaler is encoded to map with TextMarshalerNone",
+			opts: EncOptions{TextMarshaler: TextMarshalerNone},
+			in:   testTextMarshaler{String: "z"},
+			want: []byte{0xa1, 0x61, 's', 0x61, 'z'}, // {"s": "z"}
+		},
+		{
+			name: "struct implementing TextMarshaler is encoded as MarshalText's output in a text string with TextMarshalerTextString",
+			opts: EncOptions{TextMarshaler: TextMarshalerTextString},
+			tags: testTags,
+			in:   testTextMarshaler{String: "z"},
+			want: []byte{0xd9, 0x27, 0x0f, 0x61, 'z'}, // 9999("z")
+		},
+		{
+			name: "TextMarshaler struct field with omitempty is omitted if empty slice is returned using TextMarshalerTextString",
+			opts: EncOptions{TextMarshaler: TextMarshalerTextString},
+			in: struct {
+				M testTextMarshaler `cbor:"m,omitempty"`
+			}{
+				M: testTextMarshaler{String: ""},
+			},
+			want: []byte{0xa0}, // {}
+		},
+		{
+			name: "TextMarshaler struct field with omitempty is not omitted if empty slice is returned using TextMarshalerNone",
+			opts: EncOptions{TextMarshaler: TextMarshalerNone},
+			in: struct {
+				M testTextMarshaler `cbor:"m,omitempty"`
+			}{
+				M: testTextMarshaler{String: ""},
+			},
+			want: []byte{0xa1, 0x61, 'm', 0xa1, 0x61, 's', 0x60}, // {"m": {"s": ""}}
+		},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			var (
+				enc EncMode
+				err error
+			)
+
+			if tc.tags != nil {
+				enc, err = tc.opts.EncModeWithTags(tc.tags)
+			} else {
+				enc, err = tc.opts.EncMode()
+			}
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			got, err := enc.Marshal(tc.in)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			if !bytes.Equal(tc.want, got) {
+				t.Errorf("unexpected output, want: 0x%x, got 0x%x", tc.want, got)
+			}
+		})
+	}
+}
+
+func TestTextMarshalerModeError(t *testing.T) {
+	testTags := NewTagSet()
+	if err := testTags.Add(TagOptions{EncTag: EncTagRequired}, reflect.TypeOf(testTextMarshaler{}), 9999); err != nil {
+		t.Fatal(err)
+	}
+
+	for _, tc := range []struct {
+		name string
+		opts EncOptions
+		in   any
+		want string
+	}{
+		{
+			name: "non-nil error returned when MarshalText returns non-nil error",
+			opts: EncOptions{TextMarshaler: TextMarshalerTextString},
+			in:   testTextMarshaler{Error: errors.New("test")},
+			want: "cbor: cannot marshal text for cbor.testTextMarshaler: test",
+		},
+		{
+			name: "non-nil error returned when MarshalText returns non-nil error during struct field emptiness check",
+			opts: EncOptions{TextMarshaler: TextMarshalerTextString},
+			in: struct {
+				M testTextMarshaler `cbor:"m,omitempty"`
+			}{
+				M: testTextMarshaler{Error: errors.New("test")},
+			},
+			want: "cbor: cannot marshal text for cbor.testTextMarshaler: test",
+		},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			enc, err := tc.opts.EncMode()
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			_, err = enc.Marshal(tc.in)
+			if err == nil {
+				t.Fatal("expected non-nil error")
+			}
+
+			if got := err.Error(); got != tc.want {
+				t.Errorf("want: %q, got: %q", tc.want, got)
+			}
+		})
+	}
+}
diff --git a/example_test.go b/example_test.go
index 2176f95..03040c5 100644
--- a/example_test.go
+++ b/example_test.go
@@ -101,7 +101,7 @@ func ExampleMarshal_toarray() {
 }
 
 // This example uses "keyasint" struct tag option to encode struct's field names as integer.
-// This feature is very useful in handling COSE, CWT, SenML data.
+// This feautre is very useful in handling COSE, CWT, SenML data.
 func ExampleMarshal_keyasint() {
 	type Record struct {
 		Name        string `cbor:"1,keyasint"`
@@ -488,7 +488,7 @@ func Example_cOSE() {
 		IV        []byte          `cbor:"5,keyasint,omitempty"`
 		CrvOrNOrK cbor.RawMessage `cbor:"-1,keyasint,omitempty"` // K for symmetric keys, Crv for elliptic curve keys, N for RSA modulus
 		XOrE      cbor.RawMessage `cbor:"-2,keyasint,omitempty"` // X for curve x-coordinate, E for RSA public exponent
-		Y         cbor.RawMessage `cbor:"-3,keyasint,omitempty"` // Y for curve y-coordinate
+		Y         cbor.RawMessage `cbor:"-3,keyasint,omitempty"` // Y for curve y-cooridate
 		D         []byte          `cbor:"-4,keyasint,omitempty"`
 	}
 	// Data from https://tools.ietf.org/html/rfc8392#appendix-A section A.2