diff --git a/dataframe-json.cabal b/dataframe-json.cabal
--- a/dataframe-json.cabal
+++ b/dataframe-json.cabal
@@ -1,6 +1,6 @@
 cabal-version:      3.4
 name:               dataframe-json
-version:            1.2.0.2
+version:            1.2.1.0
 synopsis:           JSON reader and writer for the dataframe ecosystem.
 description:
     @DataFrame.IO.JSON@ — read/write arrays of JSON objects as
@@ -26,10 +26,11 @@
 library
     import:             warnings
     exposed-modules:    DataFrame.IO.JSON
+                        DataFrame.Typed.IO.JSON
     build-depends:      base >= 4 && < 5,
                         aeson >= 0.11.0.0 && < 3,
                         bytestring >= 0.11 && < 0.14,
-                        dataframe-core >= 2.6 && < 2.7,
+                        dataframe-core >= 2.7 && < 2.8,
                         scientific >= 0.3.1 && < 0.5,
                         text >= 2.1 && < 3,
                         vector >= 0.13 && < 0.15
diff --git a/src/DataFrame/Typed/IO/JSON.hs b/src/DataFrame/Typed/IO/JSON.hs
new file mode 100644
--- /dev/null
+++ b/src/DataFrame/Typed/IO/JSON.hs
@@ -0,0 +1,30 @@
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeApplications #-}
+
+-- | Typed JSON readers: decode, then validate against the schema.
+module DataFrame.Typed.IO.JSON (
+    readJSON,
+    readJSONEither,
+) where
+
+import qualified Data.ByteString.Lazy as LBS
+import qualified Data.Text as T
+
+import qualified DataFrame.IO.JSON as JSON
+import DataFrame.Typed.Freeze (freezeOrThrow, freezeWithError)
+import DataFrame.Typed.Schema (KnownSchema)
+import DataFrame.Typed.Types (TypedDataFrame)
+
+-- | Read JSON into a typed frame (numbers decode as Maybe Double); throws.
+readJSON ::
+    forall cols. (KnownSchema cols) => FilePath -> IO (TypedDataFrame cols)
+readJSON path = JSON.readJSON path >>= freezeOrThrow @cols
+
+-- | Decode JSON bytes into a typed DataFrame, or explain the failure.
+readJSONEither ::
+    forall cols.
+    (KnownSchema cols) =>
+    LBS.ByteString -> Either String (TypedDataFrame cols)
+readJSONEither bs = do
+    df <- JSON.readJSONEither bs
+    either (Left . T.unpack) Right (freezeWithError @cols df)
