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,17 @@
+# text-encode
+
+Classes and newtypes for deriving uniform textual encodings.
+
+This repository hosts seven packages.
+The core package is `text-encode`.
+Subsidiary packages are provided in order to avoid unnecessary dependencies in downstream packages.
+The subsidiary packages re-export the modules from the core package, so there is no need to import the core package if you are importing one or more of the subsidiary packages.
+
+- `text-encode-aeson`
+- `text-encode-cassava`
+- `text-encode-http-api-data`
+- `text-encode-persistent`
+- `text-encode-postgresql-simple`
+- `text-encode-sqlite-simple`
+
+See the module documentation in `Text.Encode` for usage.
diff --git a/src/dev-main/Main.hs b/src/dev-main/Main.hs
new file mode 100644
--- /dev/null
+++ b/src/dev-main/Main.hs
@@ -0,0 +1,2 @@
+module Main (main) where
+import DevMain (main)
diff --git a/src/dev/DevMain.hs b/src/dev/DevMain.hs
new file mode 100644
--- /dev/null
+++ b/src/dev/DevMain.hs
@@ -0,0 +1,72 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module DevMain (main) where
+
+import Text.Encode
+import Text.Encode.Aeson ()
+import Text.Encode.Cassava ()
+import Text.Encode.Persistent ()
+
+import Data.Aeson qualified as Aeson
+import Data.ByteString.Lazy (LazyByteString)
+import Data.Csv qualified as Cassava
+import Data.Functor (($>))
+import Database.Persist qualified as Persistent
+
+data MyType = MyTypeFoo | MyTypeBar | MyTypeFooBar
+    deriving stock (Read, Show, Eq, Ord, Bounded, Enum)
+  deriving TextEncode
+    via DeriveTextEncode (Cased 'Pascal 'QuietSnake (DropPrefix "MyType" ReadShowEncode)) MyType
+  deriving
+    ( Aeson.FromJSON, Aeson.ToJSON
+    , Cassava.FromField, Cassava.ToField
+    , Persistent.PersistField
+    )
+    via ViaTextEncode MyType
+
+data YourType = YourTypeFoo | YourTypeBar | YourTypeFooBar
+  deriving stock (Read, Show, Eq, Ord, Bounded, Enum)
+  deriving TextEncode
+    via DeriveTextEncode BoundedEnumEncode YourType
+  deriving
+    ( Aeson.FromJSON, Aeson.ToJSON
+    , Cassava.FromField, Cassava.ToField
+    , Persistent.PersistField
+    )
+    via ViaTextEncode YourType
+
+myCases :: [(MyType, LazyByteString)]
+myCases =
+  [ (MyTypeFoo, "foo")
+  , (MyTypeBar, "bar")
+  , (MyTypeFooBar, "foo_bar")
+  ]
+
+yourCases :: [(YourType, LazyByteString)]
+yourCases =
+  [ (YourTypeFoo, "0")
+  , (YourTypeBar, "1")
+  , (YourTypeFooBar, "2")
+  ]
+
+quoted :: LazyByteString -> LazyByteString
+quoted = ("\"" <>) . (<> "\"")
+
+checkCase :: (Eq a, Show a, Aeson.FromJSON a, Aeson.ToJSON a) => (a, LazyByteString) -> IO Int
+checkCase (dec, enc) = do
+  let enc' = quoted enc
+  putStr $ "Checking:\t" <> show dec <> " <-> " <> show enc
+  let encodingWorks = Aeson.encode dec == enc'
+  let decodingWorks = Aeson.decode enc' == Just dec
+  if encodingWorks && decodingWorks
+    then putStrLn "\tPassed." $> 0
+    else putStrLn "\tFAILED!" $> 1
+
+main :: IO ()
+main = do
+  myFailures <- traverse checkCase myCases
+  yourFailures <- traverse checkCase yourCases
+  let failures = sum (myFailures <> yourFailures)
+  if failures == 0
+    then putStrLn "All tests passed."
+    else putStrLn $ "There were " <> show failures <> " failures."
diff --git a/src/text-encode-aeson/Text/Encode/Aeson.hs b/src/text-encode-aeson/Text/Encode/Aeson.hs
new file mode 100644
--- /dev/null
+++ b/src/text-encode-aeson/Text/Encode/Aeson.hs
@@ -0,0 +1,60 @@
+module Text.Encode.Aeson (
+  module Text.Encode,
+  JsonEncode,
+) where
+
+import Text.Encode
+
+import Data.Aeson (
+  FromJSON (..),
+  FromJSONKey (..),
+  ToJSON (..),
+  ToJSONKey (..),
+  Value (..),
+  eitherDecode,
+  encode
+ )
+import Data.ByteString.Lazy.Char8 qualified as LC8
+import Data.Coerce (coerce)
+import Data.Text.Lazy qualified as LT
+import Data.Text.Lazy.Encoding qualified as LTE
+import Data.Typeable (Typeable)
+
+instance (TextEncode a, Typeable a) => FromJSON (ViaTextEncode a) where
+  parseJSON (String txt) = either fail pure $ decodeText txt
+  parseJSON raw = fail $ typedError @a "parseJSON" "Expected String, got " $ encode raw
+
+instance TextEncode a => ToJSON (ViaTextEncode a) where
+  toJSON = String . encodeText
+  toEncoding = toEncoding . encodeText
+
+  {-# INLINE toJSON #-}
+  {-# INLINE toEncoding #-}
+
+instance (TextEncode a, Typeable a) => FromJSONKey (ViaTextEncode a)
+instance TextEncode a => ToJSONKey (ViaTextEncode a)
+
+data JsonEncode
+
+instance (FromJSON a, ToJSON a) => TextEncode (DeriveTextEncode JsonEncode a) where
+  encodeLazyByteString = coerce $ encode @a
+  decodeLazyByteString = coerce $ eitherDecode @a
+  encodeByteString = LC8.toStrict . encodeLazyByteString
+  decodeByteString = decodeLazyByteString . LC8.fromStrict
+  encodeString = LC8.unpack <$> encodeLazyByteString
+  decodeString = decodeLazyByteString . LC8.pack
+  encodeLazyText = LTE.decodeLatin1 . encodeLazyByteString
+  decodeLazyText = decodeLazyByteString . LTE.encodeUtf8
+  encodeText = LT.toStrict . encodeLazyText
+  decodeText = decodeLazyText . LT.fromStrict
+
+  {-# INLINE encodeLazyByteString #-}
+  {-# INLINE decodeLazyByteString #-}
+  {-# INLINE encodeByteString #-}
+  {-# INLINE decodeByteString #-}
+  {-# INLINE encodeString #-}
+  {-# INLINE decodeString #-}
+  {-# INLINE encodeLazyText #-}
+  {-# INLINE decodeLazyText #-}
+  {-# INLINE encodeText #-}
+  {-# INLINE decodeText #-}
diff --git a/src/text-encode-cassava/Text/Encode/Cassava.hs b/src/text-encode-cassava/Text/Encode/Cassava.hs
new file mode 100644
--- /dev/null
+++ b/src/text-encode-cassava/Text/Encode/Cassava.hs
@@ -0,0 +1,39 @@
+module Text.Encode.Cassava (
+  module Text.Encode,
+  CsvEncode,
+) where
+
+import Text.Encode
+
+import Data.ByteString.Char8 qualified as C8
+import Data.Coerce (coerce)
+import Data.Csv (FromField (..), ToField (..), runParser)
+import Data.Text.Encoding qualified as T
+
+instance TextEncode a => FromField (ViaTextEncode a) where
+  parseField = either fail pure . coerce (decodeByteString @a)
+
+  {-# INLINE parseField #-}
+
+instance TextEncode a => ToField (ViaTextEncode a) where
+  toField = coerce $ encodeByteString @a
+
+  {-# INLINE toField #-}
+
+data CsvEncode
+
+instance (FromField a, ToField a) => TextEncode (DeriveTextEncode CsvEncode a) where
+  encodeByteString = coerce $ toField @a
+  decodeByteString = coerce $ runParser . parseField @a
+
+  encodeText = T.decodeLatin1 . encodeByteString
+  decodeText = decodeByteString . T.encodeUtf8
+  encodeString = C8.unpack . encodeByteString
+  decodeString = decodeByteString . C8.pack
+
+  {-# INLINE encodeByteString #-}
+  {-# INLINE decodeByteString #-}
+  {-# INLINE encodeText #-}
+  {-# INLINE decodeText #-}
+  {-# INLINE  encodeString #-}
+  {-# INLINE  decodeString #-}
diff --git a/src/text-encode-http-api-data/Text/Encode/HttpApiData.hs b/src/text-encode-http-api-data/Text/Encode/HttpApiData.hs
new file mode 100644
--- /dev/null
+++ b/src/text-encode-http-api-data/Text/Encode/HttpApiData.hs
@@ -0,0 +1,59 @@
+module Text.Encode.HttpApiData (
+  module Text.Encode,
+  UrlEncode,
+) where
+
+import Text.Encode
+
+import Data.Bifunctor (first)
+import Data.Coerce (coerce)
+import Data.Text qualified as T
+import Data.Text.Encoding qualified as TE
+import Web.FormUrlEncoded (FromFormKey (..), ToFormKey (..))
+import Web.HttpApiData (FromHttpApiData (..), ToHttpApiData (..))
+
+instance TextEncode a => ToFormKey (ViaTextEncode a) where
+  toFormKey = coerce $ encodeText @a
+
+  {-# INLINE toFormKey #-}
+
+instance TextEncode a => FromFormKey (ViaTextEncode a) where
+  parseFormKey = coerce $ first T.pack . decodeText @a
+
+  {-# INLINE parseFormKey #-}
+
+instance TextEncode a => ToHttpApiData (ViaTextEncode a) where
+  toUrlPiece = coerce $ encodeText @a
+  toHeader = TE.encodeUtf8 . toUrlPiece
+  toQueryParam = toUrlPiece
+
+  {-# INLINE toUrlPiece #-}
+  {-# INLINE toHeader #-}
+  {-# INLINE toQueryParam #-}
+
+instance TextEncode a => FromHttpApiData (ViaTextEncode a) where
+  parseUrlPiece = coerce $ first T.pack . decodeText @a
+  parseHeader = parseUrlPiece . TE.decodeLatin1
+  parseQueryParam = parseUrlPiece
+
+  {-# INLINE parseUrlPiece #-}
+  {-# INLINE parseHeader #-}
+  {-# INLINE parseQueryParam #-}
+
+data UrlEncode
+
+instance (FromHttpApiData a, ToHttpApiData a) => TextEncode (DeriveTextEncode UrlEncode a) where
+  encodeText = coerce $ toUrlPiece @a
+  decodeText = coerce $ first T.unpack . parseUrlPiece @a
+
+  encodeString = T.unpack . encodeText
+  decodeString = decodeText . T.pack
+  encodeByteString = TE.encodeUtf8 . encodeText
+  decodeByteString = decodeText . TE.decodeLatin1
+
+  {-# INLINE encodeText #-}
+  {-# INLINE decodeText #-}
+  {-# INLINE encodeString #-}
+  {-# INLINE decodeString #-}
+  {-# INLINE encodeByteString #-}
+  {-# INLINE decodeByteString #-}
diff --git a/src/text-encode-persistent/Text/Encode/Persistent.hs b/src/text-encode-persistent/Text/Encode/Persistent.hs
new file mode 100644
--- /dev/null
+++ b/src/text-encode-persistent/Text/Encode/Persistent.hs
@@ -0,0 +1,42 @@
+module Text.Encode.Persistent (
+  module Text.Encode,
+  PersistentEncode,
+) where
+
+import Text.Encode
+
+import Data.Bifunctor
+import Data.Coerce
+import Data.Text qualified as T
+import Data.Text.Encoding qualified as TE
+import Data.Typeable
+import Database.Persist.Class
+import Database.Persist.PersistValue
+
+instance (TextEncode a, Typeable a) => PersistField (ViaTextEncode a) where
+  {-# INLINE toPersistValue #-}
+  toPersistValue = coerce $ PersistText . encodeText @(ViaTextEncode a)
+
+  fromPersistValue (PersistText bs) = coerce $ first T.pack $ decodeText @(ViaTextEncode a) bs
+  fromPersistValue x = Left $ T.pack $ "DeriveTextEncode PersistentEncode requires PersistText: " <> show x
+
+data PersistentEncode
+
+instance PersistField a => TextEncode (DeriveTextEncode PersistentEncode a) where
+  encodeText (DeriveTextEncode x) =
+    case toPersistValue x of
+      (PersistText txt) -> txt
+      x' -> error $ "DeriveTextEncode PersistentEncode requires PersistText: " <> show x'
+
+  decodeText = bimap T.unpack DeriveTextEncode . fromPersistValue . PersistText
+
+  encodeByteString = TE.encodeUtf8 . encodeText
+  decodeByteString = decodeText . TE.decodeLatin1
+  encodeString = T.unpack . encodeText
+  decodeString = decodeText . T.pack
+
+  {-# INLINE decodeText #-}
+  {-# INLINE encodeByteString #-}
+  {-# INLINE decodeByteString #-}
+  {-# INLINE encodeString #-}
+  {-# INLINE decodeString #-}
diff --git a/src/text-encode-postgresql-simple/Text/Encode/PostgresqlSimple.hs b/src/text-encode-postgresql-simple/Text/Encode/PostgresqlSimple.hs
new file mode 100644
--- /dev/null
+++ b/src/text-encode-postgresql-simple/Text/Encode/PostgresqlSimple.hs
@@ -0,0 +1,32 @@
+module Text.Encode.PostgresqlSimple (
+  module Text.Encode,
+  TextEncodePostgresqlSimpleError,
+) where
+
+import Text.Encode
+
+import Control.Exception (Exception)
+import Control.Monad ((<=<))
+import Data.Coerce (coerce)
+import Data.Text qualified as T
+import Data.Typeable (Typeable)
+import Database.PostgreSQL.Simple.FromField (FromField (..), conversionError)
+import Database.PostgreSQL.Simple.ToField (ToField (..))
+
+newtype TextEncodePostgresqlSimpleError = TextEncodePostgresqlSimpleError String
+  deriving (Show)
+
+instance Exception TextEncodePostgresqlSimpleError
+
+instance (TextEncode a, Typeable a) => FromField (ViaTextEncode a) where
+  fromField f =
+    either (conversionError . TextEncodePostgresqlSimpleError) (pure . ViaTextEncode)
+      . decodeText @a
+      <=< fromField @T.Text f
+
+  {-# INLINE fromField #-}
+
+instance (TextEncode a, Typeable a) => ToField (ViaTextEncode a) where
+  toField = coerce $ toField . encodeText @a
+
+  {-# INLINE toField #-}
diff --git a/src/text-encode-sqlite-simple/Text/Encode/SqliteSimple.hs b/src/text-encode-sqlite-simple/Text/Encode/SqliteSimple.hs
new file mode 100644
--- /dev/null
+++ b/src/text-encode-sqlite-simple/Text/Encode/SqliteSimple.hs
@@ -0,0 +1,33 @@
+module Text.Encode.SqliteSimple (
+  module Text.Encode,
+  TextEncodeSqliteSimpleError,
+) where
+
+import Text.Encode
+
+import Control.Exception (Exception, toException)
+import Control.Monad ((<=<))
+import Data.Coerce (coerce)
+import Data.Text qualified as T
+import Data.Typeable (Typeable)
+import Database.SQLite.Simple.FromField (FromField (..))
+import Database.SQLite.Simple.Ok (Ok (..))
+import Database.SQLite.Simple.ToField (ToField (..))
+
+newtype TextEncodeSqliteSimpleError = TextEncodeSqliteSimpleError String
+  deriving (Show)
+
+instance Exception TextEncodeSqliteSimpleError
+
+instance (TextEncode a, Typeable a) => FromField (ViaTextEncode a) where
+  fromField =
+    either (Errors . pure . toException . TextEncodeSqliteSimpleError) (pure . ViaTextEncode)
+      . decodeText @a
+      <=< fromField @T.Text
+
+  {-# INLINE fromField #-}
+
+instance (TextEncode a, Typeable a) => ToField (ViaTextEncode a) where
+  toField = coerce $ toField . encodeText @a
+
+  {-# INLINE toField #-}
diff --git a/src/text-encode/Text/Encode.hs b/src/text-encode/Text/Encode.hs
new file mode 100644
--- /dev/null
+++ b/src/text-encode/Text/Encode.hs
@@ -0,0 +1,190 @@
+-- | This module provides classes and newtypes for deriving uniform textual encodings.
+--
+-- An instance @'TextEncode' A@ defines a uniform textual representation for data of type @A@.
+-- Writing instances is usually straightforward, or they may be derived using 'DeriveTextEncode'.
+-- Use 'ViaTextEncode' to derive instances of other classes based on a type's 'TextEncode' instance.
+--
+-- For example,
+-- @
+--     import Text.Encode
+--
+--     import Text.Encode.Aeson ()
+--     import Text.Encode.Cassava ()
+--     import Text.Encode.Persistent ()
+--
+--     data MyType = MyTypeFoo | MyTypeBar | MyTypeFooBar
+--       deriving stock (Read, Show)
+--       deriving 'TextEncode'
+--         via 'DeriveTextEncode' ('Cased' 'Pascal' 'QuietSnake' ('DropPrefix' "MyType" 'ReadShowEncode')) MyType
+--       deriving
+--         ( Aeson.FromJSON, Aeson.ToJSON
+--         , Cassava.FromField, Cassava.ToField
+--         , Persistent.PersistField
+--         )
+--         via 'ViaTextEncode' MyType
+-- @
+--
+-- This will derive a 'TextEncode' instance for @MyType@ based on the stock 'Read' and 'Show' instances,
+-- modified by the 'Cased' and 'DropPrefix' options. Uniform, mutually-consistent instances for Aeson,
+-- Cassava, and Persistent classes are then derived from the derived 'TextEncode' instance.
+module Text.Encode (
+  -- * Textual Encodings
+  TextEncode (..),
+  -- * Deriving 'TextEncode'
+  DeriveTextEncode (..),
+  -- ** Base encodings
+  BoundedEnumEncode,
+  ReadShowEncode,
+  -- ** Add or remove prefixes
+  DropPrefix,
+  AddPrefix,
+  -- ** Transform casing
+  Casing (..),
+  CaseConversion (..),
+  Cased,
+  -- * Deriving other classes
+  ViaTextEncode (..),
+  -- * Utilities
+  typedError,
+  decodeError,
+  maybeDecode,
+) where
+
+import Text.Encode.Casing
+
+import Control.Monad (guard, (<=<))
+import Data.ByteString.Char8 qualified as C8
+import Data.ByteString.Lazy.Char8 qualified as LC8
+import Data.Coerce (coerce)
+import Data.Kind (Type)
+import Data.Text qualified as T
+import Data.Text.Lazy qualified as LT
+import Data.Typeable (Typeable, Proxy (..), typeRep)
+import GHC.TypeLits (KnownSymbol, Symbol, symbolVal)
+import Text.Read (readMaybe)
+
+-- | An instance @'TextEncode' a@ defines a uniform textual representation for data of type @a@.
+--
+-- Instances are assumed to follow these mutual-coherence properties:
+-- * @decodeString . encodeString === Right@
+-- * @fmap encodeString . decodeString === Right@
+-- * @decodeText . "Data.Text".'pack' . encodeString === Right@
+-- * @fmap encodeString . decodeByteString === Right . "Data.ByteString.Char8".'unpack'@
+-- and permutations thereof.
+--
+-- Mutually-coherent default definitions of the methods are provided, though
+-- users may supply their own implementations for performance reasons.
+-- It is the user's responsibility to ensure that these definitions mutually cohere.
+class TextEncode a where
+  {-# MINIMAL encodeString, decodeString #-}
+  encodeString :: a -> String
+  decodeString :: String -> Either String a
+  encodeByteString :: a -> C8.ByteString
+  decodeByteString :: C8.ByteString -> Either String a
+  encodeLazyByteString :: a -> LC8.ByteString
+  decodeLazyByteString :: LC8.ByteString -> Either String a
+  encodeText :: a -> T.Text
+  decodeText :: T.Text -> Either String a
+  encodeLazyText :: a -> LT.Text
+  decodeLazyText :: LT.Text -> Either String a
+
+  encodeText = T.pack . encodeString
+  decodeText = decodeString . T.unpack
+  encodeLazyText = LT.fromStrict . encodeText
+  decodeLazyText = decodeText . LT.toStrict
+  encodeByteString = C8.pack . encodeString
+  decodeByteString = decodeString . C8.unpack
+  encodeLazyByteString = LC8.fromStrict . encodeByteString
+  decodeLazyByteString = decodeByteString . LC8.toStrict
+
+  {-# INLINE encodeByteString #-}
+  {-# INLINE decodeByteString #-}
+  {-# INLINE encodeLazyByteString #-}
+  {-# INLINE decodeLazyByteString #-}
+  {-# INLINE encodeText #-}
+  {-# INLINE decodeText #-}
+  {-# INLINE encodeLazyText #-}
+  {-# INLINE decodeLazyText #-}
+
+-- | Derive instances of various classes based on an instance of 'TextEncode'.
+newtype ViaTextEncode a = ViaTextEncode a
+  deriving TextEncode via a
+
+instance TextEncode a => Show (ViaTextEncode a) where
+  show (ViaTextEncode a) = encodeString a
+
+instance TextEncode a => Read (ViaTextEncode a) where
+  readsPrec _ = either (const []) (pure . (, "") . ViaTextEncode) . decodeString
+
+newtype DeriveTextEncode (opt :: Type) a = DeriveTextEncode a
+  deriving (Eq, Ord, Read, Show, Bounded, Enum) via a
+
+data BoundedEnumEncode
+
+instance (Bounded a, Enum a, Typeable a) => TextEncode (DeriveTextEncode BoundedEnumEncode a) where
+  encodeString = show . fromEnum
+  decodeString = coerce $ maybeDecode @a $ toEnumMaybe <=< readMaybe
+
+  {-# INLINE encodeString #-}
+  {-# INLINE decodeString #-}
+
+data ReadShowEncode
+
+instance (Read a, Show a, Typeable a) => TextEncode (DeriveTextEncode ReadShowEncode a) where
+  encodeString = show
+  decodeString = coerce $ maybeDecode @a readMaybe
+
+  {-# INLINE encodeString #-}
+  {-# INLINE decodeString #-}
+
+data DropPrefix (pfx :: Symbol) (opt :: Type)
+
+instance (KnownSymbol pfx, TextEncode (DeriveTextEncode opt a)) => TextEncode (DeriveTextEncode (DropPrefix pfx opt) a) where
+  encodeString = coerce $ dropPfx @pfx . encodeString @(DeriveTextEncode opt a)
+  decodeString = coerce $ decodeString @(DeriveTextEncode opt a) . (symbolVal (Proxy @pfx) <>)
+
+  {-# INLINE encodeString #-}
+  {-# INLINE decodeString #-}
+
+data AddPrefix (pfx :: Symbol) (opt :: Type)
+
+instance (KnownSymbol pfx, TextEncode (DeriveTextEncode opt a)) => TextEncode (DeriveTextEncode (AddPrefix pfx opt) a) where
+  encodeString = coerce $ (symbolVal (Proxy @pfx) <>) . encodeString @(DeriveTextEncode opt a)
+  decodeString = coerce $ decodeString @(DeriveTextEncode opt a) . dropPfx @pfx
+
+  {-# INLINE encodeString #-}
+  {-# INLINE decodeString #-}
+
+data Cased (decoding :: Casing) (encoding :: Casing) (opt :: Type)
+
+instance (CaseConversion d e, TextEncode (DeriveTextEncode opt a)) => TextEncode (DeriveTextEncode (Cased d e opt) a) where
+  encodeString = coerce $ encoding @d @e . encodeString @(DeriveTextEncode opt a)
+  decodeString = coerce $ decodeString @(DeriveTextEncode opt a) . decoding @d @e
+
+  {-# INLINE encodeString #-}
+  {-# INLINE decodeString #-}
+
+type FunctionName = String
+type Message = String
+type Input a = a
+
+typedError :: forall a b. (Typeable a, Show b) => FunctionName -> Message -> Input b -> String
+typedError fn msg input = mconcat [fn, " @", show (typeRep $ Proxy @a), ": ", msg, show input]
+
+decodeError :: forall a b. Typeable a => Input String -> Either String b
+decodeError input = Left $ typedError @a "decode" "Failed to decode " input
+
+maybeDecode :: forall a. Typeable a => (String -> Maybe a) -> Input String -> Either String a
+maybeDecode f raw = maybe (decodeError @a raw) Right $ f raw
+
+toEnumMaybe :: forall a. (Bounded a, Enum a) => Int -> Maybe a
+toEnumMaybe n = toEnum n <$ guard (fromEnum (minBound @a) <= n && n <= fromEnum (maxBound @a))
+
+dropPfx :: forall pfx. KnownSymbol pfx => String -> String
+dropPfx = drop $ length $ symbolVal $ Proxy @pfx
+
+{-# INLINE typedError #-}
+{-# INLINE decodeError #-}
+{-# INLINE maybeDecode #-}
+{-# INLINE toEnumMaybe #-}
+{-# INLINE dropPfx #-}
diff --git a/src/text-encode/Text/Encode/Casing.hs b/src/text-encode/Text/Encode/Casing.hs
new file mode 100644
--- /dev/null
+++ b/src/text-encode/Text/Encode/Casing.hs
@@ -0,0 +1,270 @@
+module Text.Encode.Casing (
+  Casing(..),
+  CaseConversion(..),
+) where
+
+import Text.Casing qualified as C
+
+data Casing
+  = Camel
+  | Kebab
+  | Pascal
+  | QuietSnake
+  | ScreamingSnake
+  | Snake
+
+class CaseConversion (decoding :: Casing) (encoding :: Casing) where
+  encoding :: String -> String
+  decoding :: String -> String
+
+instance CaseConversion 'Camel 'Camel where
+  encoding = id
+  decoding = id
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'Camel 'Kebab where
+  encoding = C.toKebab . C.fromHumps
+  decoding = C.toCamel . C.fromKebab
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'Camel 'Pascal where
+  encoding = C.toPascal . C.fromHumps
+  decoding = C.toCamel . C.fromHumps
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'Camel 'QuietSnake where
+  encoding = C.toQuietSnake . C.fromHumps
+  decoding = C.toCamel . C.fromSnake
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'Camel 'ScreamingSnake where
+  encoding = C.toScreamingSnake . C.fromHumps
+  decoding = C.toCamel . C.fromSnake
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'Camel 'Snake where
+  encoding = C.toSnake . C.fromHumps
+  decoding = C.toCamel . C.fromSnake
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'Kebab 'Camel where
+  encoding = C.toCamel . C.fromKebab
+  decoding = C.toKebab . C.fromHumps
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'Kebab 'Kebab where
+  encoding = id
+  decoding = id
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'Kebab 'Pascal where
+  encoding = C.toPascal . C.fromKebab
+  decoding = C.toKebab . C.fromHumps
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'Kebab 'QuietSnake where
+  encoding = C.toQuietSnake . C.fromKebab
+  decoding = C.toKebab . C.fromSnake
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'Kebab 'ScreamingSnake where
+  encoding = C.toScreamingSnake . C.fromKebab
+  decoding = C.toKebab . C.fromSnake
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'Kebab 'Snake where
+  encoding = C.toSnake . C.fromKebab
+  decoding = C.toKebab . C.fromSnake
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'Pascal 'Camel where
+  encoding = C.toCamel . C.fromHumps
+  decoding = C.toPascal . C.fromHumps
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'Pascal 'Kebab where
+  encoding = C.toKebab . C.fromHumps
+  decoding = C.toPascal . C.fromKebab
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'Pascal 'Pascal where
+  encoding = id
+  decoding = id
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'Pascal 'QuietSnake where
+  encoding = C.toQuietSnake . C.fromHumps
+  decoding = C.toPascal . C.fromSnake
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'Pascal 'ScreamingSnake where
+  encoding = C.toScreamingSnake . C.fromHumps
+  decoding = C.toPascal . C.fromSnake
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'Pascal 'Snake where
+  encoding = C.toSnake . C.fromHumps
+  decoding = C.toPascal . C.fromSnake
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'QuietSnake 'Camel where
+  encoding = C.toCamel . C.fromSnake
+  decoding = C.toQuietSnake . C.fromHumps
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'QuietSnake 'Kebab where
+  encoding = C.toKebab . C.fromSnake
+  decoding = C.toQuietSnake . C.fromKebab
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'QuietSnake 'Pascal where
+  encoding = C.toPascal . C.fromSnake
+  decoding = C.toQuietSnake . C.fromHumps
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'QuietSnake 'QuietSnake where
+  encoding = id
+  decoding = id
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'QuietSnake 'ScreamingSnake where
+  encoding = C.toScreamingSnake . C.fromSnake
+  decoding = C.toQuietSnake . C.fromSnake
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'QuietSnake 'Snake where
+  encoding = C.toSnake . C.fromSnake
+  decoding = C.toQuietSnake . C.fromSnake
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'ScreamingSnake 'Camel where
+  encoding = C.toCamel . C.fromSnake
+  decoding = C.toScreamingSnake . C.fromHumps
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'ScreamingSnake 'Kebab where
+  encoding = C.toKebab . C.fromSnake
+  decoding = C.toScreamingSnake . C.fromKebab
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'ScreamingSnake 'Pascal where
+  encoding = C.toPascal . C.fromSnake
+  decoding = C.toScreamingSnake . C.fromHumps
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'ScreamingSnake 'QuietSnake where
+  encoding = C.toQuietSnake . C.fromSnake
+  decoding = C.toScreamingSnake . C.fromSnake
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'ScreamingSnake 'ScreamingSnake where
+  encoding = id
+  decoding = id
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'ScreamingSnake 'Snake where
+  encoding = C.toSnake . C.fromSnake
+  decoding = C.toScreamingSnake . C.fromSnake
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'Snake 'Camel where
+  encoding = C.toCamel . C.fromSnake
+  decoding = C.toSnake . C.fromHumps
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'Snake 'Kebab where
+  encoding = C.toKebab . C.fromSnake
+  decoding = C.toSnake . C.fromKebab
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'Snake 'Pascal where
+  encoding = C.toPascal . C.fromSnake
+  decoding = C.toSnake . C.fromHumps
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'Snake 'QuietSnake where
+  encoding = C.toQuietSnake . C.fromSnake
+  decoding = C.toSnake . C.fromSnake
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'Snake 'ScreamingSnake where
+  encoding = C.toScreamingSnake . C.fromSnake
+  decoding = C.toSnake . C.fromSnake
+
+  {-# INLINE encoding #-}
+  {-# INLINE decoding #-}
+
+instance CaseConversion 'Snake 'Snake where
+  encoding = id
+  decoding = id
+
+  {-# INLINE decoding #-}
+  {-# INLINE encoding #-}
diff --git a/text-encode.cabal b/text-encode.cabal
new file mode 100644
--- /dev/null
+++ b/text-encode.cabal
@@ -0,0 +1,218 @@
+cabal-version:      3.0
+name:               text-encode
+version:            0.1.0.0
+homepage:           https://github.com/friedbrice/text-encode#readme
+bug-reports:        https://github.com/friedbrice/text-encode/issues
+author:             Daniel Brice
+maintainer:         danielbrice@gmail.com
+copyright:          Copyright Daniel Brice
+license:            BSD-3-Clause
+license-file:       LICENSE
+build-type:         Simple
+category:           Serialization
+synopsis:
+  Classes and newtypes for deriving uniform textual encodings.
+
+description:
+  Classes and newtypes for deriving uniform textual encodings.
+  See the Text.Encode module documentation for details and usage examples.
+
+extra-source-files: README.md
+tested-with:
+  GHC ==9.2.8 || ==9.4.8 || ==9.6.7 || ==9.8.4 || ==9.10.2
+
+source-repository head
+  type:     git
+  location: https://github.com/friedbrice/text-encode
+
+common dialect
+  ghc-options:        -Wall
+  build-depends:      base >=4.16 && <5
+  default-language:   Haskell2010
+  default-extensions:
+    AllowAmbiguousTypes
+    DataKinds
+    DerivingVia
+    FlexibleContexts
+    FlexibleInstances
+    ImportQualifiedPost
+    KindSignatures
+    MultiParamTypeClasses
+    ScopedTypeVariables
+    TupleSections
+    TypeApplications
+    UndecidableInstances
+
+common opts-dist
+  ghc-options: -O2
+
+common opts-dev
+  ghc-options:        -O0 -Wwarn
+  default-extensions:
+    InstanceSigs
+    PartialTypeSignatures
+
+common deps-lib
+  build-depends:
+    , bytestring  >=0.11 && <0.13
+    , casing      >=0.1  && <0.2
+    , text        >=1.2  && <2.2
+
+common deps-aeson
+  build-depends:
+    , aeson       >=2.0  && <2.3
+    , bytestring  >=0.11 && <0.13
+    , text        >=1.2  && <2.2
+
+common deps-cassava
+  build-depends:
+    , bytestring  >=0.11 && <0.13
+    , cassava     >=0.5  && <0.6
+    , text        >=1.2  && <2.2
+
+common deps-http-api-data
+  build-depends:
+    , bytestring     >=0.11 && <0.13
+    , http-api-data  >=0.4  && <0.7
+    , http-types     >=0.12 && <0.13
+    , text           >=1.2  && <2.2
+
+common deps-persistent
+  build-depends:
+    , persistent  >=2.13 && <2.18
+    , text        >=1.2  && <2.2
+
+common deps-postgresql-simple
+  build-depends:
+    , postgresql-simple  >=0.6 && <0.8
+    , text               >=1.2 && <2.2
+
+common deps-sqlite-simple
+  build-depends:
+    , sqlite-simple  >=0.4 && <0.5
+    , text           >=1.2 && <2.2
+
+common deps-dev
+
+library
+  import:          dialect
+  import:          opts-dist
+  import:          deps-lib
+  hs-source-dirs:  src/text-encode
+
+  -- cabal-fmt: expand src/text-encode
+  exposed-modules:
+    Text.Encode
+    Text.Encode.Casing
+
+library text-encode-aeson
+  import:          dialect
+  import:          opts-dist
+  import:          deps-aeson
+  visibility:      public
+  ghc-options:     -Wno-orphans
+  ghc-options:     -Wno-orphans
+  build-depends:   text-encode
+  hs-source-dirs:  src/text-encode-aeson
+
+  -- cabal-fmt: expand src/text-encode-aeson
+  exposed-modules: Text.Encode.Aeson
+
+library text-encode-cassava
+  import:          dialect
+  import:          opts-dist
+  import:          deps-cassava
+  visibility:      public
+  ghc-options:     -Wno-orphans
+  build-depends:   text-encode
+  hs-source-dirs:  src/text-encode-cassava
+
+  -- cabal-fmt: expand src/text-encode-cassava
+  exposed-modules: Text.Encode.Cassava
+
+library text-encode-http-api-data
+  import:          dialect
+  import:          opts-dist
+  import:          deps-http-api-data
+  visibility:      public
+  ghc-options:     -Wno-orphans
+  build-depends:   text-encode
+  hs-source-dirs:  src/text-encode-http-api-data
+
+  -- cabal-fmt: expand src/text-encode-http-api-data
+  exposed-modules: Text.Encode.HttpApiData
+
+library text-encode-persistent
+  import:          dialect
+  import:          opts-dist
+  import:          deps-persistent
+  visibility:      public
+  ghc-options:     -Wno-orphans
+  build-depends:   text-encode
+  hs-source-dirs:  src/text-encode-persistent
+
+  -- cabal-fmt: expand src/text-encode-persistent
+  exposed-modules: Text.Encode.Persistent
+
+library text-encode-postgresql-simple
+  import:          dialect
+  import:          opts-dist
+  import:          deps-postgresql-simple
+  visibility:      public
+  ghc-options:     -Wno-orphans
+  build-depends:   text-encode
+  hs-source-dirs:  src/text-encode-postgresql-simple
+
+  -- cabal-fmt: expand src/text-encode-postgresql-simple
+  exposed-modules: Text.Encode.PostgresqlSimple
+
+library text-encode-sqlite-simple
+  import:          dialect
+  import:          opts-dist
+  import:          deps-sqlite-simple
+  visibility:      public
+  ghc-options:     -Wno-orphans
+  build-depends:   text-encode
+  hs-source-dirs:  src/text-encode-sqlite-simple
+
+  -- cabal-fmt: expand src/text-encode-sqlite-simple
+  exposed-modules: Text.Encode.SqliteSimple
+
+test-suite dev
+  import:         dialect
+  import:         opts-dev
+  import:         deps-lib
+  import:         deps-aeson
+  import:         deps-cassava
+  import:         deps-http-api-data
+  import:         deps-persistent
+  import:         deps-postgresql-simple
+  import:         deps-sqlite-simple
+  import:         deps-dev
+  type:           exitcode-stdio-1.0
+  ghc-options:    -Wno-orphans
+  main-is:        Main.hs
+  hs-source-dirs:
+    src/text-encode src/text-encode-aeson src/text-encode-cassava
+    src/text-encode-http-api-data src/text-encode-persistent
+    src/text-encode-postgresql-simple src/text-encode-sqlite-simple
+    src/dev src/dev-main
+
+  -- cabal-fmt: expand src/text-encode
+  -- cabal-fmt: expand src/text-encode-aeson
+  -- cabal-fmt: expand src/text-encode-cassava
+  -- cabal-fmt: expand src/text-encode-http-api-data
+  -- cabal-fmt: expand src/text-encode-persistent
+  -- cabal-fmt: expand src/text-encode-postgresql-simple
+  -- cabal-fmt: expand src/text-encode-sqlite-simple
+  -- cabal-fmt: expand src/dev
+  other-modules:
+    DevMain
+    Text.Encode
+    Text.Encode.Aeson
+    Text.Encode.Casing
+    Text.Encode.Cassava
+    Text.Encode.HttpApiData
+    Text.Encode.Persistent
+    Text.Encode.PostgresqlSimple
+    Text.Encode.SqliteSimple
