diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Changelog
+
+## [0.1.0.0] - 2022-09-28
+
+- Initial release
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,19 @@
+MIT License Copyright (c) 2022 Mercury Technologies, Inc
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is furnished
+to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice (including the next
+paragraph) shall be included in all copies or substantial portions of the
+Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
+OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
+OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/src/Data/StringVariants.hs b/src/Data/StringVariants.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/StringVariants.hs
@@ -0,0 +1,64 @@
+module Data.StringVariants
+  ( -- * Non empty text
+    NonEmptyText,
+
+    -- ** Constructing
+    mkNonEmptyText,
+    unsafeMkNonEmptyText,
+    mkNonEmptyTextWithTruncate,
+    compileNonEmptyText,
+    compileNonEmptyTextKnownLength,
+
+    -- ** Converting
+    nonEmptyTextToText,
+    convertEmptyTextToNothing,
+    maybeTextToTruncateNullableNonEmptyText,
+    type (<=),
+    widen,
+
+    -- * Operations
+    takeNonEmptyText,
+    takeNonEmptyTextEnd,
+    chunksOfNonEmptyText,
+    filterNonEmptyText,
+    (<>|),
+
+    -- * Conversions between 'Refined' and 'NonEmptyText'.
+    ContainsNonWhitespaceCharacters (..),
+    exactLengthRefinedToRange,
+    nonEmptyTextFromRefined,
+    refinedFromNonEmptyText,
+
+    -- * Non-empty, whitespace-trimmed text with no character limit
+    Prose,
+    mkProse,
+    compileProse,
+    proseToText,
+
+    -- * Nullable non empty text
+    NullableNonEmptyText (..),
+    mkNullableNonEmptyText,
+    nullNonEmptyText,
+    compileNullableNonEmptyText,
+
+    -- ** Converting
+    nonEmptyTextToNullable,
+    maybeNonEmptyTextToNullable,
+    nullableNonEmptyTextToMaybeText,
+    nullableNonEmptyTextToMaybeNonEmptyText,
+    parseNullableNonEmptyText,
+    fromMaybeNullableText,
+
+    -- ** Information
+    isNullNonEmptyText,
+
+    -- * Convenience util if you need a NonEmptyText of a dynamically determined lengths
+    useNat,
+    natOfLength,
+  )
+where
+
+import Data.StringVariants.NonEmptyText
+import Data.StringVariants.NullableNonEmptyText
+import Data.StringVariants.Prose
+import Prelude ()
diff --git a/src/Data/StringVariants/NonEmptyText.hs b/src/Data/StringVariants/NonEmptyText.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/StringVariants/NonEmptyText.hs
@@ -0,0 +1,144 @@
+{-# LANGUAGE TemplateHaskell #-}
+{-# LANGUAGE TypeOperators #-}
+{-# OPTIONS_GHC -Wno-redundant-constraints #-}
+
+-- | NonEmptyText: Text that is known not to be either the empty string, or pure
+--   whitespace.
+
+module Data.StringVariants.NonEmptyText
+  ( -- * Non empty text
+    NonEmptyText,
+    type (<=),
+
+    -- * Construction
+    mkNonEmptyText,
+    unsafeMkNonEmptyText,
+    nonEmptyTextToText,
+    compileNonEmptyText,
+    compileNonEmptyTextKnownLength,
+    convertEmptyTextToNothing,
+
+    -- * Conversion
+    widen,
+
+    -- * Functions
+    takeNonEmptyText,
+    takeNonEmptyTextEnd,
+    chunksOfNonEmptyText,
+    filterNonEmptyText,
+    (<>|),
+
+    -- * Conversions between 'Refined' and 'NonEmptyText'.
+    ContainsNonWhitespaceCharacters (..),
+    exactLengthRefinedToRange,
+    nonEmptyTextFromRefined,
+    refinedFromNonEmptyText,
+
+    -- * Convenience util if you need a NonEmptyText of a dynamically determined lengths
+    useNat,
+    natOfLength,
+  )
+where
+
+import Control.Monad
+import Data.Data (Proxy (..), typeRep)
+import Data.StringVariants.NonEmptyText.Internal
+import Data.StringVariants.Util
+import Data.Text (Text)
+import Data.Text qualified as T
+import GHC.Generics (Generic)
+import GHC.TypeLits (KnownNat, natVal, type (+), type (<=))
+import Language.Haskell.TH
+import Language.Haskell.TH.Quote
+import Language.Haskell.TH.Syntax (Lift (..))
+import Refined
+import Refined.Unsafe (reallyUnsafeRefine)
+import Prelude
+
+compileNonEmptyText :: Integer -> QuasiQuoter
+compileNonEmptyText n =
+  QuasiQuoter
+    { quoteExp = compileNonEmptyText'
+    , quotePat = error "NonEmptyText is not a pattern; use nonEmptyTextToText instead"
+    , quoteDec = error "NonEmptyText is not supported at top-level"
+    , quoteType = error "NonEmptyText is not supported as a type"
+    }
+  where
+    compileNonEmptyText' :: String -> Q Exp
+    compileNonEmptyText' s = useNat n $ \p ->
+      case natOfLength p $ mkNonEmptyText (T.pack s) of
+        Nothing -> fail $ "Invalid NonEmptyText. Needs to be < " ++ show (n + 1) ++ " characters, and not entirely whitespace: " ++ s
+        Just txt -> [|$(lift txt) :: NonEmptyText $(pure $ LitT $ NumTyLit n)|]
+
+convertEmptyTextToNothing :: Text -> Maybe Text
+convertEmptyTextToNothing t
+  | textIsWhitespace t = Nothing
+  | otherwise = Just t
+
+nonEmptyTextToText :: NonEmptyText n -> Text
+nonEmptyTextToText (NonEmptyText t) = t
+
+-- | Identical to the normal text filter function, but maintains the type-level invariant
+-- that the text length is <= n, unlike unwrapping the text, filtering, then
+-- rewrapping the text.
+filterNonEmptyText :: (Char -> Bool) -> NonEmptyText n -> NonEmptyText n
+filterNonEmptyText f (NonEmptyText t) = NonEmptyText (T.filter f t)
+
+-- | Narrows the maximum length, dropping any remaining trailing characters.
+takeNonEmptyText :: forall m n. (KnownNat m, KnownNat n, n <= m) => NonEmptyText m -> NonEmptyText n
+takeNonEmptyText (NonEmptyText t) =
+  if m == n
+    then NonEmptyText t
+    else NonEmptyText $ T.take n t
+  where
+    m = fromIntegral $ natVal (Proxy @m)
+    n = fromIntegral $ natVal (Proxy @n)
+
+-- | Narrows the maximum length, dropping any prefix remaining characters.
+takeNonEmptyTextEnd :: forall m n. (KnownNat m, KnownNat n, n <= m) => NonEmptyText m -> NonEmptyText n
+takeNonEmptyTextEnd (NonEmptyText t) =
+  if m == n
+    then NonEmptyText t
+    else NonEmptyText $ T.takeEnd n t
+  where
+    m = fromIntegral $ natVal (Proxy @m)
+    n = fromIntegral $ natVal (Proxy @n)
+
+-- | /O(n)/ Splits a 'NonEmptyText' into components of length @chunkSize@. The
+-- last element may be shorter than the other chunks, depending on the length
+-- of the input.
+chunksOfNonEmptyText ::
+  forall chunkSize totalSize.
+  (KnownNat chunkSize, KnownNat totalSize) =>
+  NonEmptyText totalSize ->
+  [NonEmptyText chunkSize]
+chunksOfNonEmptyText (NonEmptyText t) =
+  map NonEmptyText (T.chunksOf chunkSize t)
+  where
+    chunkSize = fromIntegral $ natVal (Proxy @chunkSize)
+
+-- | Concat two NonEmptyText values, with the new maximum length being the sum of the two
+-- maximum lengths of the inputs.
+--
+-- Mnemonic: @<>@ for monoid, @|@ from NonEmpty's ':|' operator
+(<>|) :: NonEmptyText n -> NonEmptyText m -> NonEmptyText (n + m)
+(NonEmptyText l) <>| (NonEmptyText r) = NonEmptyText (l <> r)
+
+-- Refinery
+
+data ContainsNonWhitespaceCharacters = ContainsNonWhitespaceCharacters
+  deriving stock (Generic)
+
+instance Predicate ContainsNonWhitespaceCharacters Text where
+  validate p txt
+    | textHasNoMeaningfulContent txt = throwRefineOtherException (typeRep p) "All characters in Text input are whitespace or control characters"
+    | otherwise = Nothing
+
+exactLengthRefinedToRange :: Refined (ContainsNonWhitespaceCharacters && SizeEqualTo n) Text -> NonEmptyText n
+exactLengthRefinedToRange = NonEmptyText . unrefine
+
+nonEmptyTextFromRefined :: Refined (ContainsNonWhitespaceCharacters && (SizeLessThan n || SizeEqualTo n)) Text -> NonEmptyText n
+nonEmptyTextFromRefined = NonEmptyText . unrefine
+
+refinedFromNonEmptyText :: NonEmptyText n -> Refined (ContainsNonWhitespaceCharacters && (SizeLessThan n || SizeEqualTo n)) Text
+refinedFromNonEmptyText = reallyUnsafeRefine . nonEmptyTextToText
diff --git a/src/Data/StringVariants/NonEmptyText/Internal.hs b/src/Data/StringVariants/NonEmptyText/Internal.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/StringVariants/NonEmptyText/Internal.hs
@@ -0,0 +1,103 @@
+{-# LANGUAGE TemplateHaskell #-}
+{-# LANGUAGE TypeOperators #-}
+-- We need redundant constraints in @widen@ to enforce invariants
+{-# OPTIONS_GHC -fno-warn-redundant-constraints #-}
+
+-- | Internal module of NonEmptyText, allowing breaking the abstraction.
+--
+--   Prefer to use "Data.StringVariants.NonEmptyText" instead.
+
+module Data.StringVariants.NonEmptyText.Internal where
+
+import Control.Monad (when)
+import Data.Aeson (FromJSON (..), ToJSON, withText)
+import Data.ByteString
+import Data.Coerce
+import Data.MonoTraversable
+import Data.Proxy
+import Data.Sequences
+import Data.String.Conversions (ConvertibleStrings (..), cs)
+import Data.StringVariants.Util (textHasNoMeaningfulContent, textIsTooLong, textIsWhitespace)
+import Data.Text (Text)
+import Data.Text qualified as T
+import GHC.Generics (Generic)
+import GHC.TypeLits (KnownNat, Nat, natVal, type (<=))
+import Language.Haskell.TH.Quote
+import Language.Haskell.TH.Syntax (Lift (..), TyLit (..), Type (..))
+import Test.QuickCheck
+import Prelude
+
+-- | Non Empty Text, requires the input is between 1 and @n@ chars and not just whitespace.
+newtype NonEmptyText (n :: Nat) = NonEmptyText Text
+  deriving stock (Generic, Show, Read, Lift)
+  deriving newtype (Eq, Ord, ToJSON, Semigroup, MonoFoldable)
+
+type instance Element (NonEmptyText _n) = Char
+
+instance KnownNat n => FromJSON (NonEmptyText n) where
+  parseJSON = withText "NonEmptyText" $ \t -> do
+    performInboundValidations t
+    case mkNonEmptyText t of
+      Nothing -> fail $ "Data/StringVariants/NonEmptyText.hs: invalid NonEmptyText: " ++ T.unpack t
+      Just nonEmptyText -> pure nonEmptyText
+    where
+      -- These validations are performed at the edge of the system rather than in
+      -- mkNonEmptyText because there may be cases where legacy NonEmptyText is
+      -- stored in the database that fits one of these situations.
+      --
+      -- However, we don't want new stuff to send junk data, so we use this FromJSON
+      -- instance to validate at the edge.
+      performInboundValidations t = do
+        when (textHasNoMeaningfulContent t) $
+          fail $
+            "Data/StringVariants/NonEmptyText.hs: NonEmptyText has no meaningful content in the field: " ++ T.unpack t
+        when (T.any (== '\NUL') t) $
+          fail $
+            "Data/StringVariants/NonEmptyText.hs: NonEmptyText has a \\NUL byte in its contents: " ++ T.unpack t
+
+-- Could add more instances of this (for lazy text, bytestrings, etc) if we think we need them.
+instance ConvertibleStrings (NonEmptyText n) Text where
+  convertString (NonEmptyText t) = t
+
+instance ConvertibleStrings (NonEmptyText n) String where
+  convertString (NonEmptyText t) = cs t
+
+instance ConvertibleStrings (NonEmptyText n) ByteString where
+  convertString (NonEmptyText t) = cs t
+
+instance KnownNat n => Arbitrary (NonEmptyText n) where
+  arbitrary =
+    NonEmptyText @n <$> do
+      size <- chooseInt (1, fromIntegral (natVal (Proxy :: Proxy n)) - 1)
+      -- Mostly alphanumeric characters, all human readable
+      str <- replicateM size $ elements ['0' .. 'z']
+      pure $ T.pack str
+
+mkNonEmptyText :: forall n. KnownNat n => Text -> Maybe (NonEmptyText n)
+mkNonEmptyText t
+  | textIsTooLong stripped (fromIntegral $ natVal (Proxy @n)) = Nothing
+  | textIsWhitespace stripped = Nothing
+  | otherwise = Just (NonEmptyText stripped)
+  where
+    stripped = T.filter (/= '\NUL') $ T.strip t
+
+-- | Make a NonEmptyText when you can manually verify the length
+unsafeMkNonEmptyText :: forall n. KnownNat n => Text -> NonEmptyText n
+unsafeMkNonEmptyText = NonEmptyText
+
+-- | Converts a 'NonEmptyText' to a wider NonEmptyText
+widen :: (1 <= n, n <= m) => NonEmptyText n -> NonEmptyText m
+widen = coerce
+
+compileNonEmptyTextKnownLength :: QuasiQuoter
+compileNonEmptyTextKnownLength =
+  QuasiQuoter
+    { quoteExp = \s -> do
+        let txt = T.pack s
+        if textHasNoMeaningfulContent txt
+          then fail "Invalid NonEmptyText. Must have at least one non-whitespace, non-control character."
+          else [|NonEmptyText $(lift txt) :: NonEmptyText $(pure $ LitT $ NumTyLit $ fromIntegral $ T.length txt)|]
+    , quotePat = error "Known-length NonEmptyText is not currently supported as a pattern"
+    , quoteDec = error "NonEmptyText is not supported at top-level"
+    , quoteType = error "NonEmptyText is not supported as a type"
+    }
diff --git a/src/Data/StringVariants/NullableNonEmptyText.hs b/src/Data/StringVariants/NullableNonEmptyText.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/StringVariants/NullableNonEmptyText.hs
@@ -0,0 +1,118 @@
+{-# LANGUAGE TemplateHaskell #-}
+
+module Data.StringVariants.NullableNonEmptyText
+  ( -- Safe to construct, so we can export the contents
+    NullableNonEmptyText (..),
+
+    -- * Constructing
+    mkNonEmptyTextWithTruncate,
+    compileNullableNonEmptyText,
+    mkNullableNonEmptyText,
+    parseNullableNonEmptyText,
+    nullNonEmptyText,
+
+    -- * Conversion
+    maybeTextToTruncateNullableNonEmptyText,
+    nonEmptyTextToNullable,
+    maybeNonEmptyTextToNullable,
+    nullableNonEmptyTextToMaybeText,
+    nullableNonEmptyTextToMaybeNonEmptyText,
+    fromMaybeNullableText,
+
+    -- * Functions
+    isNullNonEmptyText,
+  )
+where
+
+import Control.Monad
+import Data.Aeson
+import Data.Aeson qualified as J
+import Data.Aeson.Key qualified as J
+import Data.Aeson.Types qualified as J
+import Data.Data (Proxy (..))
+import Data.Maybe (fromMaybe)
+import Data.StringVariants.NonEmptyText
+import Data.StringVariants.Util (textIsTooLong)
+import Data.Text (Text)
+import Data.Text qualified as T
+import GHC.Generics (Generic)
+import GHC.TypeLits (KnownNat, natVal)
+import Language.Haskell.TH
+import Language.Haskell.TH.Quote
+import Language.Haskell.TH.Syntax (Lift (..))
+import Prelude
+
+-- | Newtype wrapper around Maybe NonEmptyText that converts empty string to 'Nothing'.
+--
+--   This is aimed primarily at JSON parsing: make it possible to parse empty
+--   string and turn it into @Nothing@, in order to convert everything into
+--   @Maybe NonEmptyText@ at the edge of the system.
+--
+--   While using this for JSON parsing, use @Maybe NullableNonEmptyText@. Aeson
+--   special-cases @Maybe@ to allow nulls, so @Maybe@ catches the nulls and
+--   @NullableNonEmptyText@ catches the empty strings.
+--
+--   To extract @Maybe NonEmptyText@ values from @Maybe NullableNonEmptyText@,
+--   use 'nullableNonEmptyTextToMaybeNonEmptyText'.
+newtype NullableNonEmptyText n = NullableNonEmptyText (Maybe (NonEmptyText n))
+  deriving stock (Generic, Show, Read, Lift)
+  deriving newtype (Eq, ToJSON)
+
+mkNullableNonEmptyText :: forall n. KnownNat n => Text -> Maybe (NullableNonEmptyText n)
+mkNullableNonEmptyText t
+  | textIsTooLong t (fromIntegral $ natVal (Proxy @n)) = Nothing -- we can't store text that is too long
+  | otherwise = Just $ NullableNonEmptyText $ mkNonEmptyText t
+
+mkNonEmptyTextWithTruncate :: forall n. KnownNat n => Text -> Maybe (NonEmptyText n)
+mkNonEmptyTextWithTruncate = mkNonEmptyText . T.take (fromInteger (natVal (Proxy @n)))
+
+nullNonEmptyText :: NullableNonEmptyText n
+nullNonEmptyText = NullableNonEmptyText Nothing
+
+isNullNonEmptyText :: NullableNonEmptyText n -> Bool
+isNullNonEmptyText = (== nullNonEmptyText)
+
+instance KnownNat n => FromJSON (NullableNonEmptyText n) where
+  parseJSON = \case
+    J.String t -> case mkNullableNonEmptyText t of
+      Just txt -> pure txt
+      Nothing -> fail $ "Data/StringVariants/NullableNonEmptyText.hs: When trying to parse a NullableNonEmptyText, expected a String of length < " ++ show (natVal (Proxy @n)) ++ ", but received: " ++ T.unpack t
+    J.Null -> pure $ NullableNonEmptyText Nothing
+    x -> fail $ "Data/StringVariants/NullableNonEmptyText.hs: When trying to parse a NullableNonEmptyText, expected a String or Null, but received: " ++ show x
+
+parseNullableNonEmptyText :: KnownNat n => Text -> J.Object -> J.Parser (NullableNonEmptyText n)
+parseNullableNonEmptyText fieldName obj = obj .:? J.fromText fieldName .!= nullNonEmptyText
+
+fromMaybeNullableText :: Maybe (NullableNonEmptyText n) -> NullableNonEmptyText n
+fromMaybeNullableText = fromMaybe nullNonEmptyText
+
+nonEmptyTextToNullable :: NonEmptyText n -> NullableNonEmptyText n
+nonEmptyTextToNullable = NullableNonEmptyText . Just
+
+maybeNonEmptyTextToNullable :: Maybe (NonEmptyText n) -> NullableNonEmptyText n
+maybeNonEmptyTextToNullable Nothing = nullNonEmptyText
+maybeNonEmptyTextToNullable jt = NullableNonEmptyText jt
+
+maybeTextToTruncateNullableNonEmptyText :: forall n. KnownNat n => Maybe Text -> NullableNonEmptyText n
+maybeTextToTruncateNullableNonEmptyText mText = NullableNonEmptyText $ mText >>= mkNonEmptyText . T.take (fromInteger (natVal (Proxy @n)))
+
+nullableNonEmptyTextToMaybeNonEmptyText :: NullableNonEmptyText n -> Maybe (NonEmptyText n)
+nullableNonEmptyTextToMaybeNonEmptyText (NullableNonEmptyText t) = t
+
+nullableNonEmptyTextToMaybeText :: NullableNonEmptyText n -> Maybe Text
+nullableNonEmptyTextToMaybeText (NullableNonEmptyText t) = nonEmptyTextToText <$> t
+
+compileNullableNonEmptyText :: Integer -> QuasiQuoter
+compileNullableNonEmptyText n =
+  QuasiQuoter
+    { quoteExp = compileNullableNonEmptyText'
+    , quotePat = error "NullableNonEmptyText is not a pattern; use `nullableNonEmptyTextToMaybeText` instead"
+    , quoteDec = error "NullableNonEmptyText is not supported at top-level"
+    , quoteType = error "NullableNonEmptyText is not supported as a type"
+    }
+  where
+    compileNullableNonEmptyText' :: String -> Q Exp
+    compileNullableNonEmptyText' s = useNat n $ \p ->
+      case natOfLength p $ mkNullableNonEmptyText (T.pack s) of
+        Nothing -> fail $ "Invalid NullableNonEmptyText. Needs to be < " ++ show (n + 1) ++ " characters, and not entirely whitespace: " ++ s
+        Just txt -> [|$(lift txt)|]
diff --git a/src/Data/StringVariants/Prose.hs b/src/Data/StringVariants/Prose.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/StringVariants/Prose.hs
@@ -0,0 +1,11 @@
+-- | Prose type: arbitrary length non-empty text that is trimmed.
+module Data.StringVariants.Prose
+  ( Prose,
+    mkProse,
+    compileProse,
+    proseToText,
+  )
+where
+
+import Data.StringVariants.Prose.Internal
+import Prelude ()
diff --git a/src/Data/StringVariants/Prose/Internal.hs b/src/Data/StringVariants/Prose/Internal.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/StringVariants/Prose/Internal.hs
@@ -0,0 +1,59 @@
+{-# LANGUAGE TemplateHaskell #-}
+
+-- | Internal module of Prose, allowing breaking the abstraction.
+--
+--   Prefer to use "Data.StringVariants.Prose" instead.
+module Data.StringVariants.Prose.Internal where
+
+import Data.Aeson (FromJSON, ToJSON, ToJSONKey, withText)
+import Data.Aeson.Types (FromJSON (..))
+import Data.String.Conversions (ConvertibleStrings (..), cs)
+import Data.Text (Text)
+import Data.Text qualified as T
+import Data.Text.Lazy qualified as LT
+import Language.Haskell.TH.Quote
+import Language.Haskell.TH.Syntax
+import Prelude
+
+-- | Whitespace-trimmed, non-empty text, for use with API endpoints.
+-- The rationale is that there are many situations where if a client sends
+-- text that is empty or all whitespace, there's probably a client error.
+-- Not suitable for database fields, as there is no character limit (see
+-- 'ProsePersistFieldMsg').
+newtype Prose = Prose Text
+  deriving stock (Eq, Lift, Ord, Show)
+  deriving newtype (Semigroup, ToJSON, ToJSONKey)
+
+instance ConvertibleStrings Prose Text where
+  convertString (Prose t) = t
+
+instance ConvertibleStrings Prose LT.Text where
+  convertString (Prose t) = cs t
+
+instance FromJSON Prose where
+  parseJSON = withText "Prose" $ \t -> case mkProse t of
+    Nothing -> fail $ "Model/CustomTypes/StringVariants.hs: invalid Prose: " ++ T.unpack t
+    Just t' -> pure t'
+
+mkProse :: Text -> Maybe Prose
+mkProse t = case T.strip t of
+  "" -> Nothing
+  t' -> Just (Prose t')
+
+compileProse :: QuasiQuoter
+compileProse =
+  QuasiQuoter
+    { quoteDec = error "Prose is not supported at top-level"
+    , quoteType = error "Prose is not supported as a type"
+    , quotePat = error "Prose is not a pattern; use `proseToText` instead"
+    , ..
+    }
+  where
+    quoteExp s = case mkProse (T.pack s) of
+      Nothing -> fail (msg s)
+      Just s' -> [|$(lift s')|]
+
+    msg s = "Invalid Prose: " <> s <> ". Make sure you aren't wrapping the text in quotes."
+
+proseToText :: Prose -> Text
+proseToText (Prose txt) = txt
diff --git a/src/Data/StringVariants/Util.hs b/src/Data/StringVariants/Util.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/StringVariants/Util.hs
@@ -0,0 +1,31 @@
+module Data.StringVariants.Util (natOfLength, useNat, textIsTooLong, textIsWhitespace, textHasNoMeaningfulContent) where
+import GHC.TypeLits (KnownNat, Nat, SomeNat (..), someNatVal)
+import Prelude
+import Data.Text (Text)
+import qualified Data.Text as T
+import Data.Char (isSpace, isControl)
+
+textIsTooLong :: Text -> Int -> Bool
+-- why take? because of stream fusion,
+-- length isn't O(1), it's O(n). which means
+-- it's better to cut off the text at @n@
+-- and then evaluate the resulting stream, than
+-- to evaluate the entire stream. very unintuitive,
+-- but that's just the way things are :(
+textIsTooLong t n = T.length (T.take (n + 1) t) >= (n + 1)
+
+textIsWhitespace :: Text -> Bool
+textIsWhitespace = T.all isSpace
+
+textHasNoMeaningfulContent :: Text -> Bool
+textHasNoMeaningfulContent = T.all (\c -> isSpace c || isControl c)
+
+
+natOfLength :: proxy (n :: Nat) -> f (other n) -> f (other n)
+natOfLength _ = id
+
+useNat :: Integer -> (forall n proxy. KnownNat n => proxy n -> x) -> x
+useNat n f = case someNatVal n of
+  Nothing -> error (show n ++ " isn't a valid Nat")
+  Just (SomeNat p) -> f p
+
diff --git a/string-variants.cabal b/string-variants.cabal
new file mode 100644
--- /dev/null
+++ b/string-variants.cabal
@@ -0,0 +1,89 @@
+cabal-version: 1.12
+
+-- This file has been generated from package.yaml by hpack version 0.34.7.
+--
+-- see: https://github.com/sol/hpack
+
+name:           string-variants
+version:        0.1.0.0
+synopsis:       Constrained text newtypes
+description:    See README at <https://github.com/MercuryTechnologies/string-variants#readme>.
+category:       Data
+homepage:       https://github.com/MercuryTechnologies/string-variants#readme
+bug-reports:    https://github.com/MercuryTechnologies/string-variants/issues
+maintainer:     Jade Lovelace <jadel@mercury.com>
+license:        MIT
+license-file:   LICENSE
+build-type:     Simple
+tested-with:
+    GHC ==9.2.4 || ==9.4.2
+extra-source-files:
+    CHANGELOG.md
+
+source-repository head
+  type: git
+  location: https://github.com/MercuryTechnologies/string-variants
+
+library
+  exposed-modules:
+      Data.StringVariants
+      Data.StringVariants.NonEmptyText
+      Data.StringVariants.NonEmptyText.Internal
+      Data.StringVariants.NullableNonEmptyText
+      Data.StringVariants.Prose
+      Data.StringVariants.Prose.Internal
+      Data.StringVariants.Util
+  other-modules:
+      Paths_string_variants
+  hs-source-dirs:
+      src
+  default-extensions:
+      AllowAmbiguousTypes
+      ApplicativeDo
+      BlockArguments
+      DataKinds
+      DeriveAnyClass
+      DeriveFoldable
+      DeriveFunctor
+      DeriveGeneric
+      DeriveLift
+      DeriveTraversable
+      DerivingVia
+      FlexibleContexts
+      FlexibleInstances
+      FunctionalDependencies
+      GADTs
+      GeneralizedNewtypeDeriving
+      ImportQualifiedPost
+      InstanceSigs
+      LambdaCase
+      LexicalNegation
+      MonoLocalBinds
+      MultiWayIf
+      NamedFieldPuns
+      NumericUnderscores
+      OverloadedStrings
+      PatternSynonyms
+      PolyKinds
+      RankNTypes
+      RecordWildCards
+      RecursiveDo
+      ScopedTypeVariables
+      StandaloneDeriving
+      StandaloneKindSignatures
+      TypeApplications
+      TypeFamilies
+      ViewPatterns
+  ghc-options: -Weverything -Wno-missing-exported-signatures -Wno-missing-export-lists -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-missing-local-signatures -Wno-monomorphism-restriction -Wno-missing-safe-haskell-mode -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-missing-kind-signatures
+  build-depends:
+      QuickCheck
+    , aeson >=2.0.0.0
+    , base >=4.9 && <5
+    , bytestring
+    , mono-traversable
+    , refined
+    , refinery
+    , string-conversions
+    , template-haskell
+    , text
+  default-language: Haskell2010
