diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,40 @@
 # Changelog
 
+## [0.7.0.0] - 2026-10-07
+
+### Fixed
+
+* `optionalFieldOrNullWithOmittedDefault`, `optionalFieldOrNullWithOmittedDefault'`,
+  `optionalFieldOrNullWithOmittedDefaultWith` and
+  `optionalFieldOrNullWithOmittedDefaultWith'` now omit the key when the value
+  is the default value, as they document, instead of encoding `null`.
+  Both encodings parse back to the default value, so this changes the output
+  rather than what can be read.
+
+* `unsafeUnboundedNaturalCodec` no longer throws an arithmetic underflow when
+  it is given a negative number.  It fails to parse instead.
+
+### Removed
+
+* The `Result` newtype and its instances.
+  Nothing used it: it was left over from the time codecs it was written for.
+
+## [0.6.0.0] - 2026-07-14
+
+This is technically a breaking change but it's unlikely that you'll need to
+change any of your codecs.
+
+Thanks to Andreas Ländle (@alaendle) for contributing this feature.
+
+### Changed
+
+* Changed `StringCodec` to include `StringBounds` to represent the minimal/maximal length of a string.
+
+### Added
+
+* `textWithBoundsCodec`
+* `stringWithBoundsCodec`
+
 ## [0.5.0.0] - 2025-06-20
 
 This is technically a breaking change but it's unlikely that you'll need to
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/autodocodec.cabal b/autodocodec.cabal
--- a/autodocodec.cabal
+++ b/autodocodec.cabal
@@ -1,11 +1,11 @@
 cabal-version: 1.12
 
--- This file has been generated from package.yaml by hpack version 0.36.1.
+-- This file has been generated from package.yaml by hpack version 0.38.3.
 --
 -- see: https://github.com/sol/hpack
 
 name:           autodocodec
-version:        0.5.0.0
+version:        0.7.0.0
 synopsis:       Self-documenting encoder and decoder
 homepage:       https://github.com/NorfairKing/autodocodec#readme
 bug-reports:    https://github.com/NorfairKing/autodocodec/issues
@@ -37,6 +37,7 @@
       Paths_autodocodec
   hs-source-dirs:
       src
+  ghc-options: -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates -Wpartial-fields -Widentities -Wredundant-constraints -Wcpp-undef
   build-depends:
       aeson
     , base >=4.7 && <5
@@ -61,7 +62,7 @@
       Paths_autodocodec
   hs-source-dirs:
       doctest/
-  ghc-options: -main-is DocTest -threaded -rtsopts -with-rtsopts=-N -Wall
+  ghc-options: -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates -Wpartial-fields -Widentities -Wredundant-constraints -Wcpp-undef -main-is DocTest -threaded -rtsopts -with-rtsopts=-N -Wall
   build-depends:
       base >=4.7 && <5
     , doctest
diff --git a/src/Autodocodec/Aeson/Decode.hs b/src/Autodocodec/Aeson/Decode.hs
--- a/src/Autodocodec/Aeson/Decode.hs
+++ b/src/Autodocodec/Aeson/Decode.hs
@@ -29,7 +29,6 @@
 import qualified Data.HashMap.Strict as HashMap
 import Data.Map (Map)
 import Data.Scientific as Scientific
-import Data.Text (Text)
 import qualified Data.Text as T
 import Data.Vector (Vector)
 import qualified Data.Vector as V
@@ -66,9 +65,16 @@
       BoolCodec mname -> case mname of
         Nothing -> coerce (parseJSON value :: JSON.Parser Bool)
         Just name -> coerce $ withBool (T.unpack name) pure value
-      StringCodec mname -> case mname of
-        Nothing -> coerce (parseJSON value :: JSON.Parser Text)
-        Just name -> coerce $ withText (T.unpack name) pure value
+      StringCodec mname bounds ->
+        coerce $
+          ( \f -> case mname of
+              Nothing -> parseJSON value >>= f
+              Just name -> withText (T.unpack name) f value
+          )
+            ( \s -> case checkStringBounds bounds s of
+                Left err -> fail err
+                Right s' -> pure s'
+            )
       IntegerCodec mname bounds ->
         coerce $
           ( \f -> do
diff --git a/src/Autodocodec/Aeson/Encode.hs b/src/Autodocodec/Aeson/Encode.hs
--- a/src/Autodocodec/Aeson/Encode.hs
+++ b/src/Autodocodec/Aeson/Encode.hs
@@ -74,7 +74,7 @@
     go a = \case
       NullCodec -> JSON.Null
       BoolCodec _ -> toJSON (coerce a :: Bool)
-      StringCodec _ -> toJSON (coerce a :: Text)
+      StringCodec _ _ -> toJSON (coerce a :: Text)
       IntegerCodec _ _ -> toJSON (coerce a :: Integer)
       NumberCodec _ _ -> toJSON (coerce a :: Scientific)
       ArrayOfCodec _ c -> toJSON (fmap (`go` c) (coerce a :: Vector _))
@@ -130,7 +130,7 @@
     go a = \case
       NullCodec -> JSON.null_
       BoolCodec _ -> JSON.bool (coerce a :: Bool)
-      StringCodec _ -> JSON.text (coerce a :: Text)
+      StringCodec _ _ -> JSON.text (coerce a :: Text)
       IntegerCodec _ _ -> JSON.scientific (fromInteger (coerce a :: Integer) :: Scientific)
       NumberCodec _ _ -> JSON.scientific (coerce a :: Scientific)
       ArrayOfCodec _ c -> JSON.list (`go` c) (V.toList (coerce a :: Vector _))
diff --git a/src/Autodocodec/Codec.hs b/src/Autodocodec/Codec.hs
--- a/src/Autodocodec/Codec.hs
+++ b/src/Autodocodec/Codec.hs
@@ -6,7 +6,6 @@
 {-# LANGUAGE FlexibleContexts #-}
 {-# LANGUAGE FlexibleInstances #-}
 {-# LANGUAGE GADTs #-}
-{-# LANGUAGE GeneralizedNewtypeDeriving #-}
 {-# LANGUAGE LambdaCase #-}
 {-# LANGUAGE MultiWayIf #-}
 {-# LANGUAGE RankNTypes #-}
@@ -98,6 +97,8 @@
     (Coercible a Text, Coercible b Text) =>
     -- | Name of the @string@, for error messages and documentation.
     Maybe Text ->
+    -- | Bounds for the string, these are checked and documented
+    StringBounds ->
     ValueCodec a b
   -- | Encode 'Integer' to a @number@ value, and decode a @number@ value as an 'Integer'.
   --
@@ -296,6 +297,32 @@
     ObjectCodec input output ->
     ObjectCodec input newOutput
 
+data StringBounds = StringBounds
+  { -- | Lower bound, inclusive. A string is valid if its length is greater than, or equal to, this value.
+    stringBoundsMinLength :: !(Maybe Natural),
+    -- | Upper bound, inclusive. A string is valid if its length is less than, or equal to, this value.
+    stringBoundsMaxLength :: !(Maybe Natural)
+  }
+  deriving (Show, Eq, Ord, Generic)
+
+instance Validity StringBounds where
+  validate StringBounds {..} =
+    mconcat
+      [ validate stringBoundsMinLength,
+        validate stringBoundsMaxLength
+      ]
+
+emptyStringBounds :: StringBounds
+emptyStringBounds = StringBounds Nothing Nothing
+
+checkStringBounds :: StringBounds -> Text -> Either String Text
+checkStringBounds StringBounds {..} s =
+  case stringBoundsMinLength of
+    Just lo | T.length s < fromIntegral lo -> Left $ unwords ["String", show s, "is shorter than the lower bound", show lo]
+    _ -> case stringBoundsMaxLength of
+      Just hi | T.length s > fromIntegral hi -> Left $ unwords ["String", show s, "is longer than the upper bound", show hi]
+      _ -> Right s
+
 data Bounds a = Bounds
   { -- | Lower bound, inclusive
     boundsLower :: !(Maybe a),
@@ -378,7 +405,7 @@
 
 -- | A codec within the 'JSON.Object' context.
 --
--- An 'Object' can be used to turn a Haskell value into a 'JSON.Object' or to parse a 'JSON.Object' into a haskell value.
+-- An 'ObjectCodec' can be used to turn a Haskell value into a 'JSON.Object' or to parse a 'JSON.Object' into a haskell value.
 --
 -- This cannot be used in certain places where 'ValueCodec' could be used, and vice versa.
 type ObjectCodec = Codec JSON.Object
@@ -410,7 +437,7 @@
     go d = \case
       NullCodec -> pure $ showString "NullCodec"
       BoolCodec mName -> pure $ showParen (d > 10) $ showString "BoolCodec " . showsPrec 11 mName
-      StringCodec mName -> pure $ showParen (d > 10) $ showString "StringCodec " . showsPrec 11 mName
+      StringCodec mName mbs -> pure $ showParen (d > 10) $ showString "StringCodec " . showsPrec 11 mName . showString " " . showsPrec 11 mbs
       IntegerCodec mName mbs -> pure $ showParen (d > 10) $ showString "IntegerCodec " . showsPrec 11 mName . showString " " . showsPrec 11 mbs
       NumberCodec mName mbs -> pure $ showParen (d > 10) $ showString "NumberCodec " . showsPrec 11 mName . showString " " . showsPrec 11 mbs
       ArrayOfCodec mName c -> (\s -> showParen (d > 10) $ showString "ArrayOfCodec " . showsPrec 11 mName . showString " " . s) <$> go 11 c
@@ -1150,7 +1177,11 @@
     f = \case
       Just v -> v
       Nothing -> defaultValue
-    g v = if v == defaultValue then Nothing else Just v
+    -- Not 'if v == defaultValue then Nothing else Just v': the underlying
+    -- codec omits the key when what it is given equals its own default, which
+    -- is 'Just defaultValue', so answering 'Nothing' for the default value is
+    -- what would write @null@ instead of omitting the key.
+    g = Just
 
 -- | Like 'optionalFieldWithOmittedDefaultWith'', but the value may also be
 -- @null@ and that will be interpreted as the default value.
@@ -1165,10 +1196,11 @@
   ObjectCodec output output
 optionalFieldOrNullWithOmittedDefaultWith' key c defaultValue = dimapCodec f g $ optionalFieldWithOmittedDefaultWith' key (maybeCodec c) (Just defaultValue)
   where
+    -- See the note on 'optionalFieldOrNullWithOmittedDefaultWith'.
     f = \case
       Just v -> v
       Nothing -> defaultValue
-    g v = if v == defaultValue then Nothing else Just v
+    g = Just
 
 -- | An optional, or null, field
 --
@@ -1328,8 +1360,24 @@
 --
 -- > textCodec = StringCodec Nothing
 textCodec :: JSONCodec Text
-textCodec = StringCodec Nothing
+textCodec = StringCodec Nothing emptyStringBounds
 
+-- | Codec for 'Text' values with bounds
+--
+-- During parsing, only 'Text' values within the given boundaries are accepted.
+--
+-- During rendering, the value is not checked and is simply output as is.
+--
+-- === Example usage
+--
+-- >>> JSON.parseMaybe (parseJSONVia (textWithBoundsCodec StringBounds { stringBoundsMinLength = Just 1, stringBoundsMaxLength = Just 10})) (String "hello")
+-- Just "hello"
+--
+-- >>> JSON.parseMaybe (parseJSONVia (textWithBoundsCodec StringBounds { stringBoundsMinLength = Just 1, stringBoundsMaxLength = Just 4})) (String "hello")
+-- Nothing
+textWithBoundsCodec :: StringBounds -> JSONCodec Text
+textWithBoundsCodec bounds = StringCodec Nothing bounds
+
 -- | Codec for 'String' values
 --
 --
@@ -1353,6 +1401,35 @@
 stringCodec :: JSONCodec String
 stringCodec = dimapCodec T.unpack T.pack textCodec
 
+-- | Codec for 'String' values with bounds
+--
+-- During parsing, only 'String' values within the given boundaries are accepted.
+--
+-- During rendering, the value is not checked and is simply output as is.
+--
+-- === Example usage
+--
+-- >>> JSON.parseMaybe (parseJSONVia (stringWithBoundsCodec StringBounds { stringBoundsMinLength = Just 1, stringBoundsMaxLength = Just 10})) (String "hello")
+-- Just "hello"
+--
+-- >>> JSON.parseMaybe (parseJSONVia (stringWithBoundsCodec StringBounds { stringBoundsMinLength = Just 1, stringBoundsMaxLength = Just 4})) (String "hello")
+-- Nothing
+--
+--
+-- === WARNING
+--
+-- This codec uses 'T.unpack' and 'T.pack' to dimap a 'textWithBoundsCodec', so it __does not roundtrip__.
+--
+-- >>> toJSONVia (stringWithBoundsCodec StringBounds { stringBoundsMinLength = Just 1, stringBoundsMaxLength = Just 10}) "\55296"
+-- String "\65533"
+--
+--
+-- ==== API Note
+--
+-- This is a 'String' version of 'textWithBoundsCodec'.
+stringWithBoundsCodec :: StringBounds -> JSONCodec String
+stringWithBoundsCodec bounds = dimapCodec T.unpack T.pack (textWithBoundsCodec bounds)
+
 -- | Codec for 'Scientific' values
 --
 --
@@ -1557,8 +1634,14 @@
 unsafeUnboundedNaturalCodec =
   bimapCodec go fromIntegral scientificCodec
   where
-    go s = case Scientific.floatingOrInteger s :: Either Float Natural of
-      Right i -> Right i
+    -- Via 'Integer' rather than straight to 'Natural': 'floatingOrInteger'
+    -- reaches its result type through 'fromInteger', and 'fromInteger' into
+    -- 'Natural' throws on a negative number, so asking it for a 'Natural'
+    -- turns a number this codec should refuse into an arithmetic underflow.
+    go s = case Scientific.floatingOrInteger s :: Either Float Integer of
+      Right i
+        | i < 0 -> Left ("Number is negative: " <> show s)
+        | otherwise -> Right (fromInteger i)
       Left _ -> Left ("Number is not an integer: " <> show s)
 
 -- | A codec for a literal piece of 'Text'.
@@ -2005,17 +2088,6 @@
   Text ->
   JSONCodec a
 codecViaAeson doc = bimapCodec (JSON.parseEither JSON.parseJSON) JSON.toJSON valueCodec <?> doc
-
--- Could get this from https://hackage.haskell.org/package/either-result-0.3.1.0/docs/Control-Monad-Result.html#t:Result
--- but just reimplementing here to avoid a dependency, as it's not exported anyway
--- (well it is actually, until we give this module an explicit export list).
--- We need to do this because `Either String a` doesn't have a `MonadFail` instance,
--- but `Time.iso8601ParseM` expects it's return value to have a `MonadFail` instance.
-newtype Result a = Result {runResult :: Either String a}
-  deriving newtype (Functor, Applicative, Monad)
-
-instance MonadFail Result where
-  fail = Result . Left
 
 -- TODO 'aeson' has it's own custom datetime serialising code in the module @Data.Aeson.Encoding.Builder@:
 -- The core function here is `Data.Aeson.Encoding.Builder.timeOfDay64`.
