diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,25 @@
 # Revision history for cached-io
 
+## 1.3.0.0
+
+- **Breaking** Caching functions previously returned `m (t a)`, but it was easy to accidentally use `join` when `m` and `t` were the same monad (eg. `IO (IO a)`), and not get any caching at all. These functions now use a `Cached` newtype for `t a` to make it more difficult to misuse.
+
+### Migrating from <=1.2.0.0 to 1.3.0.0
+
+```haskell
+-- Previous versions
+f :: IO ()
+f = do
+  cachedAction <- cachedIO action :: IO (IO a)
+  cachedResult <- cachedAction
+
+-- New version
+f :: IO ()
+f = do
+  cachedAction <- cachedIO action :: IO (Cached IO a)
+  cachedResult <- runCached cachedAction
+```
+
 ## 1.2.0.0
 
 Thank you [glasserc](https://github.com/glasserc) for your work on previous versions, and a special thanks to
diff --git a/cached-io.cabal b/cached-io.cabal
--- a/cached-io.cabal
+++ b/cached-io.cabal
@@ -1,6 +1,6 @@
 cabal-version:      2.2
 name:               cached-io
-version:            1.2.0.0
+version:            1.3.0.0
 synopsis:           A simple library to cache IO actions
 description:
   Provides functions that convert an IO action into a cached one by storing the
@@ -12,8 +12,8 @@
 maintainer:         Bellroy Tech Team <haskell@bellroy.com>
 category:           Development
 build-type:         Simple
-tested-with:        GHC ==8.8.4 || ==8.10.7 || ==9.0.2 || ==9.2.1
-extra-source-files:
+tested-with:        GHC ==8.8.4 || ==8.10.7 || ==9.0.2 || ==9.2.4 || ==9.4.5 || ==9.6.2 || ==9.8.1
+extra-doc-files:
   CHANGELOG.md
   README.md
 
@@ -22,7 +22,7 @@
   location: https://github.com/bellroy/haskell-cached-io.git
 
 common deps
-  build-depends: base >=4.13.0.0 && <4.18
+  build-depends: base >=4.13.0.0 && <4.20
 
 library
   import:           deps
@@ -30,7 +30,7 @@
   build-depends:
     , exceptions    >=0.10.4  && <0.11
     , stm           >=2.5     && <2.6
-    , time          >=1.9.3   && <1.13
+    , time          >=1.9.3   && <1.15
     , transformers  >=0.5.6.2 && <0.7
 
   hs-source-dirs:   src/
diff --git a/src/Control/Concurrent/CachedIO.hs b/src/Control/Concurrent/CachedIO.hs
--- a/src/Control/Concurrent/CachedIO.hs
+++ b/src/Control/Concurrent/CachedIO.hs
@@ -3,7 +3,7 @@
 -- > -- Downloads a large payload from an external data store.
 -- > downloadData :: IO ByteString
 -- >
--- > cachedDownloadData :: IO ByteString
+-- > cachedDownloadData :: IO (Cached IO ByteString)
 -- > cachedDownloadData = cachedIO (secondsToNominalDiffTime 600) downloadData
 --
 -- The first time @cachedDownloadData@ is called, it calls @downloadData@,
@@ -14,6 +14,7 @@
 -- result again.
 --
 module Control.Concurrent.CachedIO (
+    Cached(..),
     cachedIO,
     cachedIOWith,
     cachedIO',
@@ -26,6 +27,11 @@
 import Control.Monad.IO.Class (liftIO, MonadIO)
 import Data.Time.Clock (NominalDiffTime, addUTCTime, getCurrentTime, UTCTime)
 
+-- | A cached IO action in some monad @m@. Use 'runCached' to extract the action when you want to query it.
+--
+-- Note that using 'Control.Monad.join' when the cached action and the outer monad are the same will ignore caching.
+newtype Cached m a = Cached {runCached :: m a}
+
 data State a  = Uninitialized | Initializing | Updating a | Fresh UTCTime a
 
 -- | Cache an IO action, producing a version of this IO action that is cached
@@ -37,7 +43,7 @@
 cachedIO :: (MonadIO m, MonadIO t, MonadCatch t)
          => NominalDiffTime -- ^ Number of seconds before refreshing cache
          -> t a             -- ^ IO action to cache
-         -> m (t a)
+         -> m (Cached t a)
 cachedIO interval = cachedIOWith (secondsPassed interval)
 
 -- | Cache an IO action, producing a version of this IO action that is cached
@@ -50,7 +56,7 @@
           => NominalDiffTime -- ^ Number of seconds before refreshing cache
           -> (Maybe (UTCTime, a) -> t a) -- ^ action to cache. The stale value and its refresh date
           -- are passed so that the action can perform external staleness checks
-          -> m (t a)
+          -> m (Cached t a)
 cachedIO' interval = cachedIOWith' (secondsPassed interval)
 
 -- | Check if @starting time@ + @seconds@ is after @end time@
@@ -70,7 +76,7 @@
     --   If 'isCacheStillFresh' 'lastUpdated' 'now' returns 'True'
     --   the cache is considered still fresh and returns the cached IO action
     -> t a -- ^ action to cache.
-    -> m (t a)
+    -> m (Cached t a)
 cachedIOWith f io = cachedIOWith' f (const io)
 
 -- | Cache an IO action, The cache begins uninitialized.
@@ -84,10 +90,10 @@
     --   the cache is considered still fresh and returns the cached IO action
     -> (Maybe (UTCTime, a) -> t a) -- ^ action to cache. The stale value and its refresh date
     -- are passed so that the action can perform external staleness checks
-    -> m (t a)
+    -> m (Cached t a)
 cachedIOWith' isCacheStillFresh io = do
   cachedT <- liftIO (atomically (newTVar Uninitialized))
-  return $ do
+  pure . Cached $ do
     now <- liftIO getCurrentTime
     join . liftIO . atomically $ do
       cached <- readTVar cachedT
@@ -100,12 +106,12 @@
         -- thread will get the stale data instead.
           | otherwise -> do
             writeTVar cachedT (Updating value)
-            return $ refreshCache previousState cachedT
+            pure (refreshCache previousState cachedT)
         -- Another thread is already updating the cache, just return the stale value
-        Updating value -> return (return value)
+        Updating value -> pure (pure value)
         -- The cache is uninitialized. Mark the cache as initializing to block other
         -- threads. Initialize and return.
-        Uninitialized -> return $ refreshCache Uninitialized cachedT
+        Uninitialized -> pure (refreshCache Uninitialized cachedT)
         -- The cache is uninitialized and another thread is already attempting to
         -- initialize it. Block.
         Initializing -> retry
diff --git a/test/test-cachedIO.hs b/test/test-cachedIO.hs
--- a/test/test-cachedIO.hs
+++ b/test/test-cachedIO.hs
@@ -2,7 +2,7 @@
     main
     ) where
 
-import Control.Concurrent.CachedIO (cachedIO)
+import Control.Concurrent.CachedIO (cachedIO, Cached(..))
 import Data.List (isInfixOf)
 
 crawlTheInternet :: IO [String]
@@ -13,9 +13,9 @@
     return ["website about Haskell", "website about Ruby", "slashdot.org",
             "The Monad.Reader", "haskellwiki"]
 
-searchEngine :: String -> IO [String] -> IO [String]
+searchEngine :: String -> Cached IO [String] -> IO [String]
 searchEngine query internet = do
-    pages <- internet
+    pages <- runCached internet
     return $ filter (query `isInfixOf`) pages
 
 main :: IO ()
