diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,12 @@
+Copyright (c) 2024, Ryan Trinkle
+All rights reserved.
+
+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/hash-cons.cabal b/hash-cons.cabal
new file mode 100644
--- /dev/null
+++ b/hash-cons.cabal
@@ -0,0 +1,32 @@
+name:                hash-cons
+version:             0.1.0.0
+synopsis:            Opportunistic hash-consing data structure
+description:         Provides a pure interface for hash-consing values.
+license:             BSD3
+license-file:        LICENSE
+author:              Ryan Trinkle
+maintainer:          ryan@trinkle.org
+category:            Data Structures
+build-type:          Simple
+cabal-version:       >=1.10
+
+library
+  hs-source-dirs:      src
+  exposed-modules:     Data.HashCons
+                       Data.HashCons.Internal
+  build-depends:       base >=4.9 && <5,
+                       hashable >= 1.4.0 && < 1.5
+  default-language:    Haskell2010
+
+test-suite tests
+  type:                exitcode-stdio-1.0
+  hs-source-dirs:      test
+  main-is:             Main.hs
+  build-depends:       base >=4.9 && <5,
+                       async,
+                       hash-cons,
+                       hashable,
+                       tasty,
+                       tasty-hunit,
+                       tasty-quickcheck
+  default-language:    Haskell2010
diff --git a/src/Data/HashCons.hs b/src/Data/HashCons.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/HashCons.hs
@@ -0,0 +1,7 @@
+module Data.HashCons (module X) where
+
+import Data.HashCons.Internal as X
+  ( HashCons
+  , hashCons
+  , unHashCons
+  )
diff --git a/src/Data/HashCons/Internal.hs b/src/Data/HashCons/Internal.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/HashCons/Internal.hs
@@ -0,0 +1,77 @@
+{-# LANGUAGE MagicHash #-}
+
+module Data.HashCons.Internal where
+
+import Control.Monad (when)
+import Data.Hashable (Hashable, hash, hashWithSalt)
+import Data.IORef (IORef, newIORef, readIORef, writeIORef)
+import System.IO.Unsafe (unsafeDupablePerformIO)
+import Text.ParserCombinators.ReadPrec (step)
+import Text.Read (Read(..), lexP, parens, prec)
+import Text.Read.Lex (Lexeme (Ident))
+import GHC.Exts (reallyUnsafePtrEquality#)
+
+-- | 'HashCons' with a precomputed hash and an 'IORef' to the value.
+--
+-- WARNING: Do not use this type to wrap types whose Eq or Ord instances
+-- allow distinguishable values to compare as equal; this will result in
+-- nondeterminism or even visible mutation of semantically-immutable
+-- values at runtime.
+data HashCons a = HashCons
+  { _hashCons_hash :: {-# UNPACK #-} !Int       -- ^ Precomputed hash
+  , _hashCons_ref  :: {-# UNPACK #-} !(IORef a) -- ^ Reference to the value
+  }
+
+-- | Create a new 'HashCons'.
+hashCons :: Hashable a => a -> HashCons a
+hashCons a = HashCons (hash a) $ unsafeDupablePerformIO $ newIORef a
+
+-- | Extract the value from a 'HashCons'.
+unHashCons :: HashCons a -> a
+unHashCons (HashCons _ ref) = unsafeDupablePerformIO $ readIORef ref
+
+-- | Show instance that displays 'HashCons' in the format "hashCons <x>"
+instance Show a => Show (HashCons a) where
+    showsPrec d hc = showParen (d > appPrec) $
+        showString "hashCons " . showsPrec (appPrec + 1) (unHashCons hc)
+      where
+        appPrec = 10
+
+-- | Read instance that parses 'HashCons' from the format "hashCons <x>"
+instance (Read a, Hashable a) => Read (HashCons a) where
+  readPrec = parens $ prec 10 $ do
+    Ident "hashCons" <- lexP
+    a <- step readPrec
+    pure $ hashCons a
+
+instance Eq a => Eq (HashCons a) where
+  HashCons h1 ref1 == HashCons h2 ref2
+    | ref1 == ref2 = True
+    | h1 /= h2 = False
+    | otherwise = unsafeDupablePerformIO $ do
+        a1 <- readIORef ref1
+        a2 <- readIORef ref2
+        let eq = a1 == a2
+        when eq $ case reallyUnsafePtrEquality# a1 a2 of
+          0# -> writeIORef ref1 a2
+          _ -> pure ()
+        pure eq
+
+-- | NOTE: This instance orders by hash first, and only secondarily by
+-- the 'Ord' instance of 'a', to improve performance.
+instance Ord a => Ord (HashCons a) where
+  compare (HashCons h1 ref1) (HashCons h2 ref2) = case compare h1 h2 of
+    EQ -> if ref1 == ref2
+      then EQ
+      else unsafeDupablePerformIO $ do
+        a1 <- readIORef ref1
+        a2 <- readIORef ref2
+        let result = compare a1 a2
+        when (result == EQ) $ case reallyUnsafePtrEquality# a1 a2 of
+          0# -> writeIORef ref1 a2
+          _ -> pure ()
+        pure result
+    result -> result
+
+instance Eq a => Hashable (HashCons a) where
+  hashWithSalt salt (HashCons h _) = hashWithSalt salt h
diff --git a/test/Main.hs b/test/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/Main.hs
@@ -0,0 +1,74 @@
+module Main (main) where
+
+import Control.Concurrent.Async
+import Control.Monad
+import Data.HashCons.Internal
+import Data.Hashable
+import Data.IORef
+import System.Mem.StableName
+import Test.Tasty
+import Test.Tasty.HUnit
+import Test.Tasty.QuickCheck as QC
+
+main :: IO ()
+main = defaultMain tests
+
+tests :: TestTree
+tests = testGroup "HashCons Tests"
+  [ QC.testProperty "Reflexivity" prop_reflexivity
+  , QC.testProperty "Symmetry" prop_symmetry
+  , QC.testProperty "Consistency of unHashCons" prop_consistency_unHashCons
+  , QC.testProperty "Deduplication" prop_deduplication
+  , QC.testProperty "Ord and Eq Consistency" prop_ord_eq_consistency
+  , QC.testProperty "Ord Transitivity" prop_ord_transitivity
+  ]
+
+prop_reflexivity :: Int -> Property
+prop_reflexivity x = let hx = hashCons x in hx === hx
+
+prop_symmetry :: Int -> Int -> Property
+prop_symmetry x y = let hx = hashCons x; hy = hashCons y in (hx == hy) === (hy == hx)
+
+prop_consistency_unHashCons :: Int -> Int -> Property
+prop_consistency_unHashCons x y = let hx = hashCons x; hy = hashCons y in
+  (hx == hy) === (unHashCons hx == unHashCons hy)
+
+prop_deduplication :: Int -> Int -> Property
+prop_deduplication x y = ioProperty $ do
+  let hx = hashCons x
+      hy = hashCons y
+  -- Perform the equality check to trigger deduplication
+  areEqual <- eqHashConsIO hx hy
+  if areEqual
+    then do
+      -- Read the contents of both IORefs
+      a1 <- readIORef (_hashCons_ref hx)
+      a2 <- readIORef (_hashCons_ref hy)
+      -- Create StableNames for both contents
+      sn1 <- makeStableName a1
+      sn2 <- makeStableName a2
+      -- Check if the StableNames are equal (pointer equality)
+      return $ sn1 == sn2
+    else return True -- If not equal, deduplication isn't expected
+
+-- Helper function to perform equality check in IO
+eqHashConsIO :: Eq a => HashCons a -> HashCons a -> IO Bool
+eqHashConsIO (HashCons h1 ref1) (HashCons h2 ref2)
+  | h1 /= h2     = return False
+  | ref1 == ref2 = return True
+  | otherwise    = do
+      a1 <- readIORef ref1
+      a2 <- readIORef ref2
+      if a1 == a2
+        then do
+          writeIORef ref1 a2
+          return True
+        else return False
+
+prop_ord_eq_consistency :: Int -> Int -> Bool
+prop_ord_eq_consistency x y = let hx = hashCons x; hy = hashCons y in
+  (hx == hy) == (compare hx hy == EQ)
+
+prop_ord_transitivity :: Int -> Int -> Int -> Property
+prop_ord_transitivity x y z = let hx = hashCons x; hy = hashCons y; hz = hashCons z in
+  (compare hx hy == LT && compare hy hz == LT) ==> (compare hx hz == LT)
