diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,26 @@
+Copyright 2025 Daniel Brice
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1.  Redistributions of source code must retain the above copyright notice, this
+    list of conditions and the following disclaimer.
+
+2.  Redistributions in binary form must reproduce the above copyright notice,
+    this list of conditions and the following disclaimer in the documentation
+    and/or other materials provided with the distribution.
+
+3.  Neither the name of the copyright holder nor the names of its contributors
+    may be used to endorse or promote products derived from this software
+    without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,49 @@
+# text-convert
+
+Convert between various types representing textual data. This library differs
+from the similar 'string-conversions' library in that this library exports class
+methods that are monomorphic in their return type. This enhances readability and
+aids type inference.
+
+![Haskell textual type conversions](haskell-textual-type-conversions.png)
+
+The module `Text.Convert` exports seven one-method type classes. The methods are
+designed to be input-type polymorphic and output-type monomorphic, to aid with
+readability and type inference. The classes are:
+- `ToString`, with method `asString`;
+- `ToByteString`, with method `asByteString`;
+- `ToLazyByteString`, with method `asLazyByteString`;
+- `ToByteStringBuilder`, with method `asByteStringBuilder`;
+- `ToText`, with method `asText`;
+- `ToLazyText`, with method `asLazyText`; and
+- `ToTextBuilder`, with method `asTextBuilder`.
+
+Design goals in order of importance:
+1. correctness (including totality);
+2. efficiency; and
+3. ease of use.
+The author hopes that the design achieves these goals to a high degree.
+
+To comment on correctness, we are using lenient UTF-8 decoding when converting
+between `ByteString`s and `Text`s, which means the conversions are correct in
+the sense that they are total, but they are incorrect in the sense that they may
+replace some unicode characters with the usual replacement character. Since this
+library serves a very general purpose, users will not necessarily place any
+restrictions on the inputs of these conversion functions, so the author thought
+that totality was important. Notably, this choice agrees with the behavior of
+many text editors and readers that use similar decoding strategies.
+
+To comment on efficiency, we avoid unnecessary intermediate conversions where
+possible. For example, instead of converting `Text` to `ByteString` through
+`String` (a design admirable in that it is simple, correct, and a quantity of
+code that is linear in number of supported datatypes), we choose to use a
+bespoke conversion function for every pair of supported datatypes wherever
+possible (resulting in a quadratic quantity of code). We also avoid unnecessary
+allocation. For example, instead of converting `LazyByteString` to `Text`
+through `LazyText` (an approach that would entail creating a new rope of `Text`
+that would immediately be converted into a single array), we convert
+`LazyByteString` to `Text` through `ByteString` (which is a single array). We
+don't have any benchmarks to support this design choice, admittedly, as we
+consider the cost of creating a benchmark suite overrides the benefits of having
+one at this time. Should interest in this library grow, we may revisit this
+decision.
diff --git a/src/tests-main/tests-main.hs b/src/tests-main/tests-main.hs
new file mode 100644
--- /dev/null
+++ b/src/tests-main/tests-main.hs
@@ -0,0 +1,2 @@
+module Main (main) where
+import TestsMain (main)
diff --git a/src/tests/TestsMain.hs b/src/tests/TestsMain.hs
new file mode 100644
--- /dev/null
+++ b/src/tests/TestsMain.hs
@@ -0,0 +1,77 @@
+module TestsMain where
+
+import Text.Convert
+
+import Data.ByteString.Builder qualified as BB
+import Data.ByteString.Char8 qualified as BC
+import Data.ByteString.Lazy.Char8 qualified as BLC
+import Data.Text qualified as T
+import Data.Text.Encoding qualified as TE
+import Data.Text.Encoding.Error qualified as TEE
+import Data.Text.Lazy qualified as TL
+import Data.Text.Lazy.Builder qualified as TLB
+import Data.Text.Lazy.Encoding qualified as TLE
+import Data.Typeable (Typeable, typeRep)
+import Test.Hspec (Spec, describe, hspec, shouldBe)
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck.Arbitrary (Arbitrary)
+
+main :: IO ()
+main = hspec $ do
+  describe "asString" $ do
+    identityProp asString id id "asString"
+    weakInverseProp asString id id "BC.pack" BC.pack
+    weakInverseProp asString id id "BLC.pack" BLC.pack
+    weakInverseProp asString id id "T.pack" T.pack
+    weakInverseProp asString id id "TL.pack" TL.pack
+    weakInverseProp asString id id "TLB.fromString" TLB.fromString
+
+  describe "asByteString" $ do
+    identityProp asByteString BC.pack id "asByteString"
+    weakInverseProp asByteString BC.pack id "BC.unpack" BC.unpack
+    weakInverseProp asByteString BC.pack id "BLC.fromStrict" BLC.fromStrict
+    weakInverseProp asByteString BC.pack id "TE.decodeUtf8With TEE.lenientDecode" (TE.decodeUtf8With TEE.lenientDecode)
+    weakInverseProp asByteString BC.pack BB.toLazyByteString "BB.byteString" BB.byteString
+
+  describe "asLazyByteString" $ do
+    identityProp asLazyByteString BLC.pack id "asLazyByteString"
+    weakInverseProp asLazyByteString BLC.pack id "id" BLC.unpack
+    weakInverseProp asLazyByteString BLC.pack id "id" BLC.toStrict
+    weakInverseProp asLazyByteString BLC.pack id "TLE.decodeUtf8With TEE.lenientDecode" (TLE.decodeUtf8With TEE.lenientDecode)
+    weakInverseProp asLazyByteString BLC.pack BB.toLazyByteString "BB.lazyByteString" BB.lazyByteString
+
+  describe "asText" $ do
+    identityProp asText T.pack id "asText"
+    weakInverseProp asText T.pack id "T.unpack" T.unpack
+    weakInverseProp asText T.pack id "TE.encodeUtf8" TE.encodeUtf8
+    weakInverseProp asText T.pack id "TL.fromStrict" TL.fromStrict
+    weakInverseProp asText T.pack id "TLB.fromText" TLB.fromText
+
+  describe "asLazyText" $ do
+    identityProp asLazyText TL.pack id "asLazyText"
+    weakInverseProp asLazyText TL.pack id "TL.unpack" TL.unpack
+    weakInverseProp asLazyText TL.pack id "TLE.encodeUtf8" TLE.encodeUtf8
+    weakInverseProp asLazyText TL.pack id "TL.toStrict" TL.toStrict
+    weakInverseProp asLazyText TL.pack id "TLB.fromLazyText" TLB.fromLazyText
+
+  describe "asTextBuilder" $ do
+    identityProp asTextBuilder TLB.fromString id "asTextBuilder"
+    weakInverseProp asTextBuilder TLB.fromString id "TLB.toLazyText" TLB.toLazyText
+
+  describe "asByteStringBuilder" $ do
+    identityProp asByteStringBuilder BB.stringUtf8 BB.toLazyByteString "asByteStringBuilder"
+    weakInverseProp asByteStringBuilder BB.stringUtf8 id "BB.toLazyByteString" BB.toLazyByteString
+
+identityProp :: forall a c t.
+  (Arbitrary a, Typeable t, Show a, Show c, Eq c) =>
+  (t -> t) -> (a -> t) -> (t -> c) -> String -> Spec
+identityProp testFn gen cmp lbl =
+  prop (lbl <> " should be the identity on " <> show (typeRep $ Nothing @t)) $
+    \x -> (cmp . testFn . gen) x `shouldBe` (cmp . gen) x
+
+weakInverseProp :: forall a c s t.
+  (Arbitrary a, Typeable s, Show a, Show c, Eq c) =>
+  (s -> t) -> (a -> t) -> (s -> c) -> String -> (t -> s) -> Spec
+weakInverseProp testFn gen cmp lbl inv =
+  prop (lbl <> " should be a weak inverse through " <> show (typeRep $ Nothing @s)) $
+    \x -> (cmp . inv . testFn . inv . gen) x `shouldBe` (cmp . inv . gen) x
diff --git a/src/text-convert/Text/Convert.hs b/src/text-convert/Text/Convert.hs
new file mode 100644
--- /dev/null
+++ b/src/text-convert/Text/Convert.hs
@@ -0,0 +1,303 @@
+{- | Convert between various textual representation.
+
+This module exports seven one-method type classes. The methods are
+designed to be input-type polymorphic and output-type monomorphic, to aid with
+readability and type inference. The classes are:
+- `ToString`, with method `asString`;
+- `ToByteString`, with method `asByteString`;
+- `ToLazyByteString`, with method `asLazyByteString`;
+- `ToByteStringBuilder`, with method `asByteStringBuilder`;
+- `ToText`, with method `asText`;
+- `ToLazyText`, with method `asLazyText`; and
+- `ToTextBuilder`, with method `asTextBuilder`.
+
+Design goals in order of importance:
+1. correctness (including totality);
+2. efficiency; and
+3. ease of use.
+The author hopes that the design achieves these goals to a high degree.
+
+To comment on correctness, we are using lenient UTF-8 decoding when converting
+between `ByteString`s and `Text`s, which means the conversions are correct in
+the sense that they are total, but they are incorrect in the sense that they may
+replace some unicode characters with the usual replacement character. Since this
+library serves a very general purpose, users will not necessarily place any
+restrictions on the inputs of these conversion functions, so the author thought
+that totality was important. Notably, this choice agrees with the behavior of
+many text editors and readers that use similar decoding strategies.
+
+To comment on efficiency, we avoid unnecessary intermediate conversions where
+possible. For example, instead of converting `Text` to `ByteString` through
+`String` (a design admirable in that it is simple, correct, and a quantity of
+code that is linear in number of supported datatypes), we choose to use a
+bespoke conversion function for every pair of supported datatypes wherever
+possible (resulting in a quadratic quantity of code). We also avoid unnecessary
+allocation. For example, instead of converting `LazyByteString` to `Text`
+through `LazyText` (an approach that would entail creating a new rope of `Text`
+that would immediately be converted into a single array), we convert
+`LazyByteString` to `Text` through `ByteString` (which is a single array). We
+don't have any benchmarks to support this design choice, admittedly, as we
+consider the cost of creating a benchmark suite overrides the benefits of having
+one at this time. Should interest in this library grow, we may revisit this
+decision.
+-}
+module Text.Convert (
+  ToString(..),
+  ToByteString(..),
+  ToLazyByteString(..),
+  ToByteStringBuilder(..),
+  ToText(..),
+  ToLazyText(..),
+  ToTextBuilder(..),
+  String,
+  ByteString,
+  LazyByteString,
+  Text,
+  LazyText,
+) where
+
+import Data.Text (Text)
+import Data.ByteString (ByteString)
+import Data.ByteString.Builder qualified as BB
+import Data.ByteString.Char8 qualified as BC
+import Data.ByteString.Lazy.Char8 qualified as BLC
+import Data.Text qualified as T
+import Data.Text.Encoding qualified as TE
+import Data.Text.Encoding.Error qualified as TEE
+import Data.Text.Lazy qualified as TL
+import Data.Text.Lazy.Builder qualified as TLB
+import Data.Text.Lazy.Encoding qualified as TLE
+
+type LazyByteString = BLC.ByteString
+type LazyText = TL.Text
+
+class ToString a where
+  asString :: a -> String
+
+class ToByteString a where
+  asByteString :: a -> BC.ByteString
+
+class ToLazyByteString a where
+  asLazyByteString :: a -> BLC.ByteString
+
+class ToByteStringBuilder a where
+  asByteStringBuilder :: a -> BB.Builder
+
+class ToText a where
+  asText :: a -> T.Text
+
+class ToLazyText a where
+  asLazyText :: a -> TL.Text
+
+class ToTextBuilder a where
+  asTextBuilder :: a -> TLB.Builder
+
+-- ToString instances
+
+instance ToString String where
+  asString = id
+  {-# INLINE asString #-}
+
+instance ToString BC.ByteString where
+  asString = BC.unpack
+  {-# INLINE asString #-}
+
+instance ToString BLC.ByteString where
+  asString = BLC.unpack
+  {-# INLINE asString #-}
+
+instance ToString BB.Builder where
+  asString = BLC.unpack . BB.toLazyByteString
+  {-# INLINE asString #-}
+
+instance ToString T.Text where
+  asString = T.unpack
+  {-# INLINE asString #-}
+
+instance ToString TL.Text where
+  asString = TL.unpack
+  {-# INLINE asString #-}
+
+instance ToString TLB.Builder where
+  asString = TL.unpack . TLB.toLazyText
+  {-# INLINE asString #-}
+
+-- ToByteString instances
+
+instance ToByteString String where
+  asByteString = BC.pack
+  {-# INLINE asByteString #-}
+
+instance ToByteString BC.ByteString where
+  asByteString = id
+  {-# INLINE asByteString #-}
+
+instance ToByteString BLC.ByteString where
+  asByteString = BLC.toStrict
+  {-# INLINE asByteString #-}
+
+instance ToByteString BB.Builder where
+  asByteString = BLC.toStrict . BB.toLazyByteString
+  {-# INLINE asByteString #-}
+
+instance ToByteString T.Text where
+  asByteString = TE.encodeUtf8
+  {-# INLINE asByteString #-}
+
+instance ToByteString TL.Text where
+  asByteString = TE.encodeUtf8 . TL.toStrict
+  {-# INLINE asByteString #-}
+
+instance ToByteString TLB.Builder where
+  asByteString = TE.encodeUtf8 . TL.toStrict . TLB.toLazyText
+  {-# INLINE asByteString #-}
+
+-- ToLazyByteString instances
+
+instance ToLazyByteString String where
+  asLazyByteString = BLC.pack
+  {-# INLINE asLazyByteString #-}
+
+instance ToLazyByteString BC.ByteString where
+  asLazyByteString = BLC.fromStrict
+  {-# INLINE asLazyByteString #-}
+
+instance ToLazyByteString BLC.ByteString where
+  asLazyByteString = id
+  {-# INLINE asLazyByteString #-}
+
+instance ToLazyByteString BB.Builder where
+  asLazyByteString = BB.toLazyByteString
+  {-# INLINE asLazyByteString #-}
+
+instance ToLazyByteString T.Text where
+  asLazyByteString = BLC.fromStrict . TE.encodeUtf8
+  {-# INLINE asLazyByteString #-}
+
+instance ToLazyByteString TL.Text where
+  asLazyByteString = TLE.encodeUtf8
+  {-# INLINE asLazyByteString #-}
+
+instance ToLazyByteString TLB.Builder where
+  asLazyByteString = TLE.encodeUtf8 . TLB.toLazyText
+  {-# INLINE asLazyByteString #-}
+
+-- ToByteStringBuilder instances
+
+instance ToByteStringBuilder String where
+  asByteStringBuilder = BB.stringUtf8
+  {-# INLINE asByteStringBuilder #-}
+
+instance ToByteStringBuilder BC.ByteString where
+  asByteStringBuilder = BB.byteString
+  {-# INLINE asByteStringBuilder #-}
+
+instance ToByteStringBuilder BLC.ByteString where
+  asByteStringBuilder = BB.lazyByteString
+  {-# INLINE asByteStringBuilder #-}
+
+instance ToByteStringBuilder BB.Builder where
+  asByteStringBuilder = id
+  {-# INLINE asByteStringBuilder #-}
+
+instance ToByteStringBuilder T.Text where
+  asByteStringBuilder = BB.byteString . TE.encodeUtf8
+  {-# INLINE asByteStringBuilder #-}
+
+instance ToByteStringBuilder TL.Text where
+  asByteStringBuilder = BB.lazyByteString . TLE.encodeUtf8
+  {-# INLINE asByteStringBuilder #-}
+
+instance ToByteStringBuilder TLB.Builder where
+  asByteStringBuilder = BB.lazyByteString . TLE.encodeUtf8 . TLB.toLazyText
+  {-# INLINE asByteStringBuilder #-}
+
+-- ToText instances
+
+instance ToText String where
+  asText = T.pack
+  {-# INLINE asText #-}
+
+instance ToText BC.ByteString where
+  asText = TE.decodeUtf8With TEE.lenientDecode
+  {-# INLINE asText #-}
+
+instance ToText BLC.ByteString where
+  asText = undefined
+  {-# INLINE asText #-}
+
+instance ToText BB.Builder where
+  asText = undefined
+  {-# INLINE asText #-}
+
+instance ToText T.Text where
+  asText = id
+  {-# INLINE asText #-}
+
+instance ToText TL.Text where
+  asText = TL.toStrict
+  {-# INLINE asText #-}
+
+instance ToText TLB.Builder where
+  asText = TL.toStrict . TLB.toLazyText
+  {-# INLINE asText #-}
+
+-- ToLazyText instances
+
+instance ToLazyText String where
+  asLazyText = TL.pack
+  {-# INLINE asLazyText #-}
+
+instance ToLazyText BC.ByteString where
+  asLazyText = TL.fromStrict . TE.decodeUtf8With TEE.lenientDecode
+  {-# INLINE asLazyText #-}
+
+instance ToLazyText BLC.ByteString where
+  asLazyText = TLE.decodeUtf8With TEE.lenientDecode
+  {-# INLINE asLazyText #-}
+
+instance ToLazyText BB.Builder where
+  asLazyText = TLE.decodeUtf8With TEE.lenientDecode . BB.toLazyByteString
+  {-# INLINE asLazyText #-}
+
+instance ToLazyText T.Text where
+  asLazyText = TL.fromStrict
+  {-# INLINE asLazyText #-}
+
+instance ToLazyText TL.Text where
+  asLazyText = id
+  {-# INLINE asLazyText #-}
+
+instance ToLazyText TLB.Builder where
+  asLazyText = TLB.toLazyText
+  {-# INLINE asLazyText #-}
+
+-- ToTextBuilder instances
+
+instance ToTextBuilder String where
+  asTextBuilder = TLB.fromString
+  {-# INLINE asTextBuilder #-}
+
+instance ToTextBuilder BC.ByteString where
+  asTextBuilder = TLB.fromText . TE.decodeUtf8With TEE.lenientDecode
+  {-# INLINE asTextBuilder #-}
+
+instance ToTextBuilder BLC.ByteString where
+  asTextBuilder = TLB.fromLazyText . TLE.decodeUtf8With TEE.lenientDecode
+  {-# INLINE asTextBuilder #-}
+
+instance ToTextBuilder BB.Builder where
+  asTextBuilder = TLB.fromLazyText . TLE.decodeUtf8With TEE.lenientDecode . BB.toLazyByteString
+  {-# INLINE asTextBuilder #-}
+
+instance ToTextBuilder T.Text where
+  asTextBuilder = TLB.fromText
+  {-# INLINE asTextBuilder #-}
+
+instance ToTextBuilder TL.Text where
+  asTextBuilder = TLB.fromLazyText
+  {-# INLINE asTextBuilder #-}
+
+instance ToTextBuilder TLB.Builder where
+  asTextBuilder = id
+  {-# INLINE asTextBuilder #-}
diff --git a/text-convert.cabal b/text-convert.cabal
new file mode 100644
--- /dev/null
+++ b/text-convert.cabal
@@ -0,0 +1,85 @@
+cabal-version:      2.2
+name:               text-convert
+version:            0.1.0.0
+category:           Data
+synopsis:           Convert between various textual representation.
+description:
+  Convert between various types representing textual data. This library differs
+  from the similar 'string-conversions' library in that this library exports
+  class methods that are monomorphic in their return type. This enhances
+  readability and aids type inference.
+
+homepage:           https://github.com/friedbrice/text-convert#readme
+bug-reports:        https://github.com/friedbrice/text-convert/issues
+author:             Daniel Brice
+maintainer:         danielbrice@gmail.com
+copyright:          2025 Daniel Brice
+license:            BSD-3-Clause
+license-file:       LICENSE
+extra-source-files: README.md
+build-type:         Simple
+tested-with:
+  GHC ==9.2.8
+   || ==9.4.8
+   || ==9.6.7
+   || ==9.8.4
+   || ==9.10.2
+   || ==9.12.2
+
+source-repository head
+  type:     git
+  location: https://github.com/friedbrice/text-convert
+
+common dialect
+  default-language:   Haskell2010
+  ghc-options:        -Wall
+  default-extensions:
+    FlexibleInstances
+    ImportQualifiedPost
+    ScopedTypeVariables
+    TypeApplications
+
+  build-depends:
+    , base        >=4.16 && <5
+    , bytestring  >=0.11 && <0.13
+    , text        >=1.2  && <2.2
+
+common deps-tests
+  build-depends:
+    , hspec
+    , QuickCheck
+    , text-convert
+
+library
+  import:          dialect
+  hs-source-dirs:  src/text-convert
+
+  -- cabal-fmt: expand src/text-convert
+  exposed-modules: Text.Convert
+
+test-suite tests
+  import:         dialect
+  import:         deps-tests
+  type:           exitcode-stdio-1.0
+  build-depends:  text-convert
+  hs-source-dirs: src/tests src/tests-main
+  main-is:        tests-main.hs
+
+  -- cabal-fmt: expand src/tests
+  other-modules:  TestsMain
+
+test-suite dev
+  import:             dialect
+  import:             deps-tests
+  type:               exitcode-stdio-1.0
+  hs-source-dirs:     src/text-convert src/tests src/tests-main
+  main-is:            tests-main.hs
+  default-extensions:
+    InstanceSigs
+    PartialTypeSignatures
+
+  -- cabal-fmt: expand src/text-convert
+  -- cabal-fmt: expand src/tests
+  other-modules:
+    TestsMain
+    Text.Convert
