diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -5,10 +5,6 @@
 
 Precise Haskell representations of PostgreSQL data types with mappings to and from both binary and textual formats.
 
-## Status
-
-In active development, but already working and exhaustively tested.
-
 ## Key Features
 
 ### Ecosystem Integration
@@ -32,6 +28,7 @@
 - **Bit string types**: Bit, Varbit
 - **UUID type**: Uuid
 - **JSON types**: Json, Jsonb
+- **Full-text search types**: Tsvector
 - **Key-value types**: Hstore
 - **Range types**: Range (supporting int4range, int8range, numrange, tsrange, tstzrange, daterange)
 - **Multirange types**: Multirange (supporting int4multirange, int8multirange, etc.)
diff --git a/postgresql-types.cabal b/postgresql-types.cabal
--- a/postgresql-types.cabal
+++ b/postgresql-types.cabal
@@ -1,6 +1,6 @@
 cabal-version: 3.0
 name: postgresql-types
-version: 0.1.1.1
+version: 0.1.2
 category: PostgreSQL, Codecs
 synopsis: Precise PostgreSQL types representation and driver-agnostic codecs
 description:
@@ -134,6 +134,7 @@
     PostgresqlTypes.Polygon
     PostgresqlTypes.Range
     PostgresqlTypes.Text
+    PostgresqlTypes.Tsvector
     PostgresqlTypes.Time
     PostgresqlTypes.Timestamp
     PostgresqlTypes.Timestamptz
@@ -240,6 +241,7 @@
     PostgresqlTypes.RangeSpec
     PostgresqlTypes.TextSpec
     PostgresqlTypes.TimeSpec
+    PostgresqlTypes.TsvectorSpec
     PostgresqlTypes.TimestampSpec
     PostgresqlTypes.TimestamptzSpec
     PostgresqlTypes.TimetzSpec
diff --git a/src/integration-tests/Main.hs b/src/integration-tests/Main.hs
--- a/src/integration-tests/Main.hs
+++ b/src/integration-tests/Main.hs
@@ -71,6 +71,7 @@
           withType @PostgresqlTypes.Timestamp [mappingSpec]
           withType @PostgresqlTypes.Timestamptz [mappingSpec]
           withType @PostgresqlTypes.Timetz [mappingSpec]
+          withType @PostgresqlTypes.Tsvector [mappingSpec]
           withType @PostgresqlTypes.Uuid [mappingSpec]
           withType @(PostgresqlTypes.Varbit 0) [mappingSpec]
           withType @(PostgresqlTypes.Varbit 128) [mappingSpec]
@@ -125,6 +126,7 @@
           withType @PostgresqlTypes.Timestamp [mappingSpec]
           withType @PostgresqlTypes.Timestamptz [mappingSpec]
           withType @PostgresqlTypes.Timetz [mappingSpec]
+          withType @PostgresqlTypes.Tsvector [mappingSpec]
           withType @PostgresqlTypes.Uuid [mappingSpec]
           withType @(PostgresqlTypes.Varbit 0) [mappingSpec]
           withType @(PostgresqlTypes.Varbit 128) [mappingSpec]
@@ -179,6 +181,7 @@
           withType @PostgresqlTypes.Timestamp [mappingSpec]
           withType @PostgresqlTypes.Timestamptz [mappingSpec]
           withType @PostgresqlTypes.Timetz [mappingSpec]
+          withType @PostgresqlTypes.Tsvector [mappingSpec]
           withType @PostgresqlTypes.Uuid [mappingSpec]
           withType @(PostgresqlTypes.Varbit 0) [mappingSpec]
           withType @(PostgresqlTypes.Varbit 128) [mappingSpec]
diff --git a/src/library/PostgresqlTypes.hs b/src/library/PostgresqlTypes.hs
--- a/src/library/PostgresqlTypes.hs
+++ b/src/library/PostgresqlTypes.hs
@@ -60,6 +60,9 @@
     -- * Key-Value Types
     Hstore,
 
+    -- * Full-Text Search Types
+    Tsvector,
+
     -- * Range Types
     Range,
     Multirange,
@@ -102,6 +105,7 @@
 import PostgresqlTypes.Timestamp
 import PostgresqlTypes.Timestamptz
 import PostgresqlTypes.Timetz
+import PostgresqlTypes.Tsvector
 import PostgresqlTypes.Uuid
 import PostgresqlTypes.Varbit
 import PostgresqlTypes.Varchar
diff --git a/src/library/PostgresqlTypes/Multirange/QuickCheckGen.hs b/src/library/PostgresqlTypes/Multirange/QuickCheckGen.hs
--- a/src/library/PostgresqlTypes/Multirange/QuickCheckGen.hs
+++ b/src/library/PostgresqlTypes/Multirange/QuickCheckGen.hs
@@ -1,9 +1,8 @@
-module PostgresqlTypes.Multirange.QuickCheckGen
-where
+module PostgresqlTypes.Multirange.QuickCheckGen where
 
+import qualified Data.Set as Set
 import PostgresqlTypes.Prelude
 import Test.QuickCheck
-import qualified Data.Set as Set
 
 -- | Attention. This generator may run indefinitely if the 'elementGen' has too small variety of possible values.
 setOfSize :: (Ord a) => Int -> Gen a -> Gen (Set.Set a)
diff --git a/src/library/PostgresqlTypes/Tsvector.hs b/src/library/PostgresqlTypes/Tsvector.hs
new file mode 100644
--- /dev/null
+++ b/src/library/PostgresqlTypes/Tsvector.hs
@@ -0,0 +1,289 @@
+module PostgresqlTypes.Tsvector
+  ( Tsvector,
+
+    -- * Accessors
+    toLexemeList,
+
+    -- * Constructors
+    refineFromLexemeList,
+    normalizeFromLexemeList,
+
+    -- * Weight
+    Weight (..),
+  )
+where
+
+import qualified Data.Attoparsec.Text as Attoparsec
+import qualified Data.ByteString as ByteString
+import qualified Data.List as List
+import qualified Data.Map.Strict as Map
+import qualified Data.Text as Text
+import qualified Data.Text.Encoding as Text.Encoding
+import qualified Data.Vector as Vector
+import PostgresqlTypes.Algebra
+import PostgresqlTypes.Prelude
+import PostgresqlTypes.Via
+import qualified PtrPeeker
+import qualified PtrPoker.Write as Write
+import qualified Test.QuickCheck as QuickCheck
+import qualified TextBuilder
+
+-- | Weight of a tsvector lexeme position.
+data Weight = AWeight | BWeight | CWeight | DWeight
+  deriving stock (Eq, Ord, Show, Read, Enum, Bounded)
+
+instance Arbitrary Weight where
+  arbitrary = QuickCheck.elements [AWeight, BWeight, CWeight, DWeight]
+
+instance Hashable Weight where
+  hashWithSalt salt = hashWithSalt salt . fromEnum
+
+-- | PostgreSQL @tsvector@ type. Full-text search document representation.
+--
+-- A tsvector is a sorted list of distinct lexemes with optional position and weight information.
+-- Lexemes are sorted alphabetically and deduplicated, matching PostgreSQL's canonical representation.
+--
+-- [PostgreSQL docs](https://www.postgresql.org/docs/18/datatype-textsearch.html).
+data Tsvector = Tsvector (Vector (Text, Vector (Word16, Weight)))
+  deriving stock (Eq, Ord)
+  deriving (Show, Read, IsString) via (ViaIsScalar Tsvector)
+
+instance Hashable Tsvector where
+  hashWithSalt salt (Tsvector lexemes) =
+    Vector.foldl' (\s (t, ps) -> Vector.foldl' (\s' (p, w) -> s' `hashWithSalt` p `hashWithSalt` fromEnum w) (s `hashWithSalt` t) ps) salt lexemes
+
+instance Arbitrary Tsvector where
+  arbitrary = do
+    size <- QuickCheck.getSize
+    numLexemes <- QuickCheck.choose (0, max 0 size)
+    lexemeMap <-
+      Map.fromList <$> QuickCheck.vectorOf numLexemes do
+        -- Generate a non-empty lexeme token without NUL characters
+        token <-
+          Text.pack
+            <$> QuickCheck.listOf1
+              (QuickCheck.suchThat arbitrary (\c -> c /= '\NUL'))
+        numPositions <- QuickCheck.choose (0, 3)
+        positions <-
+          sortAndDedupPositions <$> QuickCheck.vectorOf numPositions do
+            pos <- QuickCheck.choose (1, 16383)
+            weight <- arbitrary
+            pure (pos, weight)
+        pure (token, positions)
+    -- Sort by lexeme (Map.toAscList) and deduplicate (Map guarantees unique keys)
+    let sorted = Map.toAscList lexemeMap
+    pure (Tsvector (Vector.fromList (map (\(t, ps) -> (t, Vector.fromList ps)) sorted)))
+  shrink (Tsvector lexemes) =
+    map (\ls -> normalizeLexemes (map (\(t, ps) -> (t, Vector.fromList ps)) ls)) $
+      QuickCheck.shrinkList
+        ( \(tok, positions) -> do
+            shrunkenTok <- QuickCheck.shrinkMap Text.pack Text.unpack tok
+            shrunkenPositions <- QuickCheck.shrinkList shrink positions
+            pure (shrunkenTok, shrunkenPositions)
+        )
+        (map (\(t, ps) -> (t, Vector.toList ps)) (Vector.toList lexemes))
+
+-- | Sort lexemes alphabetically and deduplicate by lexeme text, merging positions.
+-- Positions within each lexeme are sorted by position number and deduplicated
+-- (keeping the highest weight for duplicate positions), matching PostgreSQL's canonical form.
+normalizeLexemes :: [(Text, Vector (Word16, Weight))] -> Tsvector
+normalizeLexemes lexemes =
+  let m = Map.fromListWith (<>) (map (\(t, ps) -> (t, Vector.toList ps)) lexemes)
+      sorted = Map.toAscList m
+   in Tsvector (Vector.fromList (map (\(t, ps) -> (t, Vector.fromList (sortAndDedupPositions ps))) sorted))
+
+-- | Sort positions by position number ascending, deduplicating by position
+-- (keeping the minimum weight, i.e. highest priority: A < B < C < D).
+sortAndDedupPositions :: [(Word16, Weight)] -> [(Word16, Weight)]
+sortAndDedupPositions =
+  map (foldr1 (\(p, w1) (_, w2) -> (p, min w1 w2)))
+    . List.groupBy (\a b -> fst a == fst b)
+    . List.sortOn fst
+
+instance IsScalar Tsvector where
+  schemaName = Tagged Nothing
+  typeName = Tagged "tsvector"
+  baseOid = Tagged (Just 3614)
+  arrayOid = Tagged (Just 3643)
+  typeParams = Tagged []
+
+  -- Binary format:
+  -- 4 bytes: number of lexemes (int32)
+  -- Per lexeme:
+  --   N bytes: lexeme text as null-terminated UTF-8 string
+  --   2 bytes: number of positions (uint16)
+  --   Per position:
+  --     2 bytes: uint16 where bits 14-15 = weight (A=3,B=2,C=1,D=0), bits 0-13 = position
+  binaryEncoder (Tsvector lexemes) =
+    Write.bInt32 (fromIntegral (Vector.length lexemes))
+      <> Vector.foldMap encodeLexeme lexemes
+    where
+      encodeLexeme (token, positions) =
+        let tokenBytes = Text.Encoding.encodeUtf8 token
+         in Write.byteString tokenBytes
+              <> Write.word8 0 -- null terminator
+              <> Write.bWord16 (fromIntegral (Vector.length positions))
+              <> Vector.foldMap encodePosition positions
+      encodePosition (pos, weight) =
+        let weightBits = case weight of
+              AWeight -> 3
+              BWeight -> 2
+              CWeight -> 1
+              DWeight -> 0
+            -- PostgreSQL tsvector positions must be in the range 1..16383.
+            -- Clamp here to avoid silent truncation by bit masking.
+            posClamped = max 1 (min 16383 pos)
+         in Write.bWord16 ((weightBits `shiftL` 14) .|. posClamped)
+
+  binaryDecoder = runExceptT do
+    numLexemes <- lift $ PtrPeeker.fixed PtrPeeker.beSignedInt4
+    when (numLexemes < 0) do
+      throwError
+        ( DecodingError
+            { location = ["tsvector", "lexemeCount"],
+              reason =
+                ParsingDecodingErrorReason
+                  (fromString "Negative lexeme count in tsvector binary data")
+                  ByteString.empty
+            }
+        )
+    lexemes <- Vector.fromList <$> replicateM (fromIntegral numLexemes) decodeLexeme
+    pure (Tsvector lexemes)
+    where
+      decodeLexeme = do
+        -- Read null-terminated UTF-8 string
+        tokenBytes <- lift PtrPeeker.nullTerminatedStringAsByteString
+        case Text.Encoding.decodeUtf8' tokenBytes of
+          Left e ->
+            throwError
+              ( DecodingError
+                  { location = ["tsvector", "lexeme"],
+                    reason = ParsingDecodingErrorReason (fromString (show e)) tokenBytes
+                  }
+              )
+          Right token
+            | Text.null token ->
+                throwError
+                  ( DecodingError
+                      { location = ["tsvector", "lexeme"],
+                        reason =
+                          ParsingDecodingErrorReason
+                            (fromString "empty lexeme is not allowed in tsvector")
+                            tokenBytes
+                      }
+                  )
+            | otherwise -> do
+                numPositions <- lift $ PtrPeeker.fixed PtrPeeker.beUnsignedInt2
+                positions <-
+                  Vector.fromList <$> replicateM (fromIntegral numPositions) do
+                    posWord <- lift $ PtrPeeker.fixed PtrPeeker.beUnsignedInt2
+                    let weightBits = (posWord `shiftR` 14) .&. 0x3
+                    let weight = case weightBits of
+                          3 -> AWeight
+                          2 -> BWeight
+                          1 -> CWeight
+                          _ -> DWeight
+                    let pos = posWord .&. 0x3FFF
+                    pure (pos, weight)
+                pure (token, positions)
+
+  -- Text format: 'lexeme1':1A,2B 'lexeme2':3C
+  -- Single quotes are escaped as '', backslashes as \\
+  textualEncoder (Tsvector lexemes) =
+    TextBuilder.intercalateMap " " encodeLexeme (Vector.toList lexemes)
+    where
+      encodeLexeme (token, positions) =
+        TextBuilder.char '\''
+          <> TextBuilder.text (escapeToken token)
+          <> TextBuilder.char '\''
+          <> if Vector.null positions
+            then mempty
+            else TextBuilder.char ':' <> TextBuilder.intercalateMap "," encodePosition (Vector.toList positions)
+      encodePosition (pos, weight) =
+        TextBuilder.string (show pos)
+          <> case weight of
+            AWeight -> TextBuilder.char 'A'
+            BWeight -> TextBuilder.char 'B'
+            CWeight -> TextBuilder.char 'C'
+            DWeight -> mempty -- D is default, omitted by PostgreSQL
+      escapeToken = Text.concatMap escapeChar
+      escapeChar c = case c of
+        '\'' -> "''"
+        '\\' -> "\\\\"
+        _ -> Text.singleton c
+
+  textualDecoder = do
+    -- Allow and ignore leading whitespace before the first lexeme
+    Attoparsec.skipSpace
+    lexemes <- lexemeParser `Attoparsec.sepBy` space1
+    -- Allow and ignore trailing whitespace after the last lexeme
+    Attoparsec.skipSpace
+    -- Sort and deduplicate to match PostgreSQL's canonical form
+    let Tsvector normalized = normalizeLexemes (map (\(t, ps) -> (t, Vector.fromList ps)) lexemes)
+    pure (Tsvector normalized)
+    where
+      -- Consume one or more space / tab / newline characters between lexemes
+      space1 = do
+        _ <- Attoparsec.takeWhile1 (\c -> c == ' ' || c == '\t' || c == '\n')
+        pure ()
+      lexemeParser = do
+        _ <- Attoparsec.char '\''
+        token <- parseToken
+        _ <- Attoparsec.char '\''
+        positions <- parsePositions <|> pure []
+        pure (token, positions)
+      parseToken = do
+        chars <- many (escapedQuote <|> escapedBackslash <|> normalChar)
+        pure (Text.pack chars)
+      escapedQuote = do
+        _ <- Attoparsec.string "''"
+        pure '\''
+      escapedBackslash = do
+        _ <- Attoparsec.string "\\\\"
+        pure '\\'
+      normalChar = Attoparsec.satisfy (\c -> c /= '\'' && c /= '\\')
+      parsePositions = do
+        _ <- Attoparsec.char ':'
+        parsePosition `Attoparsec.sepBy1` Attoparsec.char ','
+      parsePosition = do
+        pos <- Attoparsec.decimal @Integer
+        when (pos < 1 || pos > 16383) do
+          fail ("tsvector position out of range 1..16383: " <> show pos)
+        let pos' = fromIntegral pos :: Word16
+        weight <-
+          asum
+            [ Attoparsec.char 'A' $> AWeight,
+              Attoparsec.char 'B' $> BWeight,
+              Attoparsec.char 'C' $> CWeight,
+              Attoparsec.char 'D' $> DWeight,
+              pure DWeight
+            ]
+        pure (pos', weight)
+
+-- * Accessors
+
+-- | Extract the tsvector as a list of (lexeme, positions) pairs.
+-- Lexemes are in sorted order. Each position is a (position, weight) pair where position is 1-16383.
+toLexemeList :: Tsvector -> [(Text, [(Word16, Weight)])]
+toLexemeList (Tsvector lexemes) =
+  map (\(t, ps) -> (t, Vector.toList ps)) (Vector.toList lexemes)
+
+-- * Constructors
+
+-- | Construct a tsvector from a list of (lexeme, positions) pairs with validation.
+-- Returns 'Nothing' if any lexeme is empty, contains null characters,
+-- or has positions outside the valid range 1..16383.
+-- Sorts and deduplicates lexemes to match PostgreSQL's canonical representation.
+refineFromLexemeList :: [(Text, [(Word16, Weight)])] -> Maybe Tsvector
+refineFromLexemeList lexemes =
+  if any (\(t, ps) -> Text.null t || Text.elem '\NUL' t || any (\(p, _) -> p < 1 || p > 16383) ps) lexemes
+    then Nothing
+    else Just (normalizeLexemes (map (\(t, ps) -> (t, Vector.fromList ps)) lexemes))
+
+-- | Construct a tsvector from a list of (lexeme, positions) pairs.
+-- Strips null characters from lexemes and removes empty lexemes.
+-- Sorts and deduplicates lexemes to match PostgreSQL's canonical representation.
+normalizeFromLexemeList :: [(Text, [(Word16, Weight)])] -> Tsvector
+normalizeFromLexemeList lexemes =
+  let cleaned = filter (\(t, _) -> not (Text.null t)) $ map (\(t, ps) -> (Text.replace "\NUL" "" t, ps)) lexemes
+   in normalizeLexemes (map (\(t, ps) -> (t, Vector.fromList ps)) cleaned)
diff --git a/src/unit-tests/PostgresqlTypes/TsvectorSpec.hs b/src/unit-tests/PostgresqlTypes/TsvectorSpec.hs
new file mode 100644
--- /dev/null
+++ b/src/unit-tests/PostgresqlTypes/TsvectorSpec.hs
@@ -0,0 +1,163 @@
+module PostgresqlTypes.TsvectorSpec (spec) where
+
+import Data.Data (Proxy (Proxy))
+import qualified Data.Text as Text
+import qualified PostgresqlTypes.Tsvector as Tsvector
+import Test.Hspec
+import Test.QuickCheck
+import qualified UnitTests.Scripts as Scripts
+import Prelude
+
+spec :: Spec
+spec = do
+  describe "Show/Read laws" do
+    Scripts.testShowRead (Proxy @Tsvector.Tsvector)
+
+  describe "IsScalar laws" do
+    Scripts.testIsScalar (Proxy @Tsvector.Tsvector)
+
+  describe "Constructors" do
+    describe "refineFromLexemeList" do
+      it "creates Tsvector from valid lexemes" do
+        let result = Tsvector.refineFromLexemeList [("hello", [(1, Tsvector.AWeight)]), ("world", [(2, Tsvector.BWeight)])]
+        result `shouldSatisfy` (/= Nothing)
+
+      it "rejects empty lexeme tokens" do
+        Tsvector.refineFromLexemeList [("", [(1, Tsvector.AWeight)])] `shouldBe` Nothing
+
+      it "rejects lexeme tokens with null characters" do
+        Tsvector.refineFromLexemeList [("hel\NULlo", [(1, Tsvector.AWeight)])] `shouldBe` Nothing
+
+      it "creates Tsvector from empty list" do
+        let result = Tsvector.refineFromLexemeList []
+        result `shouldSatisfy` (/= Nothing)
+        fmap Tsvector.toLexemeList result `shouldBe` Just []
+
+      it "sorts lexemes alphabetically" do
+        let Just tsvec = Tsvector.refineFromLexemeList [("banana", []), ("apple", [])]
+            lexemes = map fst (Tsvector.toLexemeList tsvec)
+        lexemes `shouldBe` ["apple", "banana"]
+
+      it "deduplicates lexemes" do
+        let Just tsvec = Tsvector.refineFromLexemeList [("hello", [(1, Tsvector.AWeight)]), ("hello", [(2, Tsvector.BWeight)])]
+            lexemes = map fst (Tsvector.toLexemeList tsvec)
+        length lexemes `shouldBe` 1
+
+    describe "normalizeFromLexemeList" do
+      it "strips null characters from tokens" do
+        let tsvec = Tsvector.normalizeFromLexemeList [("hel\NULlo", [(1, Tsvector.AWeight)])]
+            lexemes = Tsvector.toLexemeList tsvec
+        map fst lexemes `shouldBe` ["hello"]
+
+      it "removes empty lexemes after stripping" do
+        let tsvec = Tsvector.normalizeFromLexemeList [("\NUL", [(1, Tsvector.AWeight)])]
+            lexemes = Tsvector.toLexemeList tsvec
+        lexemes `shouldBe` []
+
+      it "handles empty input" do
+        let tsvec = Tsvector.normalizeFromLexemeList []
+        Tsvector.toLexemeList tsvec `shouldBe` []
+
+  describe "Accessors" do
+    describe "toLexemeList" do
+      it "extracts lexemes with positions" do
+        let Just tsvec = Tsvector.refineFromLexemeList [("test", [(1, Tsvector.AWeight), (2, Tsvector.CWeight)])]
+            [(token, positions)] = Tsvector.toLexemeList tsvec
+        token `shouldBe` "test"
+        length positions `shouldBe` 2
+
+  describe "Weight" do
+    it "has correct ordering" do
+      Tsvector.AWeight `shouldSatisfy` (< Tsvector.BWeight)
+      Tsvector.BWeight `shouldSatisfy` (< Tsvector.CWeight)
+      Tsvector.CWeight `shouldSatisfy` (< Tsvector.DWeight)
+
+    it "has correct Enum instances" do
+      [Tsvector.AWeight ..] `shouldBe` [Tsvector.AWeight, Tsvector.BWeight, Tsvector.CWeight, Tsvector.DWeight]
+
+  describe "Textual encoding" do
+    it "encodes empty tsvector" do
+      let Just tsvec = Tsvector.refineFromLexemeList []
+       in show tsvec `shouldBe` "\"\""
+
+    it "encodes lexeme without positions" do
+      let Just tsvec = Tsvector.refineFromLexemeList [("hello", [])]
+       in show tsvec `shouldBe` "\"'hello'\""
+
+    it "encodes lexeme with single weighted position" do
+      let Just tsvec = Tsvector.refineFromLexemeList [("hello", [(1, Tsvector.AWeight)])]
+       in show tsvec `shouldBe` "\"'hello':1A\""
+
+    it "encodes lexeme with default weight (D) by omitting it" do
+      let Just tsvec = Tsvector.refineFromLexemeList [("hello", [(1, Tsvector.DWeight)])]
+       in show tsvec `shouldBe` "\"'hello':1\""
+
+    it "encodes multiple lexemes" do
+      let Just tsvec = Tsvector.refineFromLexemeList [("apple", [(1, Tsvector.AWeight)]), ("banana", [(2, Tsvector.BWeight)])]
+       in show tsvec `shouldBe` "\"'apple':1A 'banana':2B\""
+
+    it "escapes single quotes in lexemes" do
+      let Just tsvec = Tsvector.refineFromLexemeList [("it's", [(1, Tsvector.AWeight)])]
+       in show tsvec `shouldBe` "\"'it''s':1A\""
+
+    it "escapes backslashes in lexemes" do
+      let Just tsvec = Tsvector.refineFromLexemeList [("back\\slash", [(1, Tsvector.AWeight)])]
+       in show tsvec `shouldBe` "\"'back\\\\\\\\slash':1A\""
+
+  describe "Textual decoding" do
+    it "decodes empty string" do
+      Tsvector.toLexemeList (read "\"\"") `shouldBe` []
+
+    it "decodes lexeme without positions" do
+      let tsvec = read "\"'hello'\"" :: Tsvector.Tsvector
+          [(token, positions)] = Tsvector.toLexemeList tsvec
+      token `shouldBe` "hello"
+      positions `shouldBe` []
+
+    it "decodes lexeme with weighted position" do
+      let tsvec = read "\"'hello':1A\"" :: Tsvector.Tsvector
+          [(token, positions)] = Tsvector.toLexemeList tsvec
+      token `shouldBe` "hello"
+      positions `shouldBe` [(1, Tsvector.AWeight)]
+
+    it "decodes lexeme with default weight" do
+      let tsvec = read "\"'hello':1\"" :: Tsvector.Tsvector
+          [(_, positions)] = Tsvector.toLexemeList tsvec
+      positions `shouldBe` [(1, Tsvector.DWeight)]
+
+    it "decodes multiple positions" do
+      let tsvec = read "\"'hello':1A,2B,3C\"" :: Tsvector.Tsvector
+          [(_, positions)] = Tsvector.toLexemeList tsvec
+      length positions `shouldBe` 3
+
+    it "decodes escaped single quotes" do
+      let tsvec = read "\"'it''s':1A\"" :: Tsvector.Tsvector
+          [(token, _)] = Tsvector.toLexemeList tsvec
+      token `shouldBe` "it's"
+
+    it "decodes escaped backslashes" do
+      let tsvec = read "\"'back\\\\\\\\slash':1A\"" :: Tsvector.Tsvector
+          [(token, _)] = Tsvector.toLexemeList tsvec
+      token `shouldBe` "back\\slash"
+
+  describe "Property Tests" do
+    it "roundtrips through toLexemeList and refineFromLexemeList" do
+      property \(tsvec :: Tsvector.Tsvector) ->
+        let lexemes = Tsvector.toLexemeList tsvec
+            reconstructed = Tsvector.refineFromLexemeList lexemes
+         in reconstructed === Just tsvec
+
+    it "refineFromLexemeList is idempotent via normalization" do
+      property \(tsvec :: Tsvector.Tsvector) ->
+        let lexemes = Tsvector.toLexemeList tsvec
+            Just tsvec' = Tsvector.refineFromLexemeList lexemes
+            lexemes' = Tsvector.toLexemeList tsvec'
+            Just tsvec'' = Tsvector.refineFromLexemeList lexemes'
+         in tsvec' === tsvec''
+
+    it "normalizeFromLexemeList produces same result as refineFromLexemeList for valid input" do
+      property \(tsvec :: Tsvector.Tsvector) ->
+        let lexemes = Tsvector.toLexemeList tsvec
+            normalized = Tsvector.normalizeFromLexemeList lexemes
+            Just refined = Tsvector.refineFromLexemeList lexemes
+         in normalized === refined
