diff --git a/dataframe-json.cabal b/dataframe-json.cabal
--- a/dataframe-json.cabal
+++ b/dataframe-json.cabal
@@ -1,7 +1,6 @@
-cabal-version:      2.4
+cabal-version:      3.4
 name:               dataframe-json
-version:            1.0.1.1
-
+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
@@ -27,12 +26,13 @@
 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.13,
-                        dataframe-core ^>= 1.1,
-                        scientific >= 0.3.1 && < 0.4,
-                        text >= 2.0 && < 3,
-                        vector ^>= 0.13
+                        bytestring >= 0.11 && < 0.14,
+                        dataframe-core >= 2.7 && < 2.8,
+                        scientific >= 0.3.1 && < 0.5,
+                        text >= 2.1 && < 3,
+                        vector >= 0.13 && < 0.15
     hs-source-dirs:     src
     default-language:   Haskell2010
diff --git a/src/DataFrame/IO/JSON.hs b/src/DataFrame/IO/JSON.hs
--- a/src/DataFrame/IO/JSON.hs
+++ b/src/DataFrame/IO/JSON.hs
@@ -34,7 +34,7 @@
         let col = buildColumn rows c
         pure (c, col)
 
-    pure $ D.fromNamedColumns columns
+    pure $ D.fromColumns columns
 
 readJSON :: FilePath -> IO D.DataFrame
 readJSON path = do
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)
