diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,52 @@
 # Revision history for convex-schema-parser
 
+## 0.1.4.0 -- 2025-07-09
+
+*   **Fixes:**
+    *   Properly unify nested types during the unification pass.
+
+## 0.1.3.0 -- 2025-07-08
+
+*   **Schema Parsing:**
+    *   More robust implementation of the schema parser provides better error handling and more accurate parsing.
+    *   Correctly handles foreign imported types.
+    *   Support for `i64`, `f64`, `ArrayBuffer`, and `v.bytes()` has been added.
+
+*   **Backend Improvements:**
+    *   **Rust:**
+        *   Custom `serde` implementation for `convex::Value`.
+        *   Validator for generated types.
+        *   Typed subscription streams for Convex subscriptions.
+    *   **Python:**
+        *   Validator for generated types.
+        *   Pydantic annotations to handle fields prefixed with `_`.
+        *   Typed subscription generators for Convex subscriptions.
+
+*   **Developer Experience:**
+    *   New `init` command to guide users through the setup process.
+    *   `optparse-applicative` for a better CLI experience.
+    *   New "dev mode" to automatically regenerate code on file changes.
+    *   `justfile` to provide a simple way to run common commands.
+
+*   **Fixes:**
+    *   **Rust Backend:**
+        *   Correctly use the inner optional value when generating functions.
+        *   Fixed a bug where `innerValueConversion` was not being properly handled for functions.
+        *   Custom structs now know how to encode themselves to `convex::Value`.
+    *   **Python Backend:**
+        *   Removed redundant imports from the generated Python code.
+        *   Properly indent code with helpers.
+        *   Reverted to a sync client.
+    *   **General:**
+        *   Normalized generated strings for easier testing.
+        *   Namespaced functions to avoid conflicts in the Python and Rust backends.
+        *   Fixed a bug where empty function arguments were not being properly handled in the action parser.
+
+*   **Miscellaneous:**
+    *   Updated README and LICENSE.
+    *   Added a CI/CD pipeline for releases.
+    *   Support for GHC 9.6.7.
+
 ## 0.1.0 -- 2025-06-30
 
 * First version
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -14,6 +14,10 @@
 ## Installation
 
 The easiest way to use `convex-schema-parser` is currently through the Cabal package manager.
+
+> [!NOTE]
+> We are on hackage now! So you can simply run `cabal update` (important, this package is a recent addition to Hackage) and then `cabal install convex-schema-parser` to install the tool quickly without cloning.
+
 We provide prebuilt binaries for `linux` & `macOS` that you can download and run directly, but `macOS` users have to allow the binary to run first since we do not sign it (yet).
 A `npm` package `@parsonosai/convex-schema-parser` is also on its way and supported as soon as we get code-signing ready, we currently use a placeholder.
 
diff --git a/convex-schema-parser.cabal b/convex-schema-parser.cabal
--- a/convex-schema-parser.cabal
+++ b/convex-schema-parser.cabal
@@ -1,6 +1,6 @@
 cabal-version:   3.0
 name:            convex-schema-parser
-version:         0.1.3.0
+version:         0.1.4.0
 license:         MIT
 author:          Norbert Dzikowski
 maintainer:      lambdax.one@icloud.com
@@ -26,6 +26,24 @@
 common warnings
     ghc-options: -Wall
 
+-- library convex-client
+--     import:           warnings
+--     exposed-modules:  Convex.Client.Client
+--         , Convex.Client.Types
+--     build-depends:    base >= 4.18.3 && < 4.21
+--         , websockets >= 0.12.7 && < 0.13
+--         , wuss >= 1.1.1 && < 1.2
+--         , async >= 2.2.4 && < 2.3
+--         , aeson >= 2.2.3 && < 2.3
+--         , stm >= 2.5.3 && < 2.6
+--         , text >= 2.1 && < 2.2
+--         , uuid >= 1.3.13 && < 1.4
+--         , data-default >= 0.7.1 && < 0.8
+--         , mtl >= 2.3.1 && < 2.4
+--         , containers >= 0.7 && < 0.8
+--     hs-source-dirs:   src
+--     default-language: Haskell2010
+
 library convex-schema-parser-lib
     import:           warnings
     exposed-modules:  Convex.Schema.Parser
@@ -75,6 +93,7 @@
         , ApiParserTest
         , SchemaParserTest
         , RustSerializationTest
+        , UnificationTest
     hs-source-dirs:      test
     build-depends:       base,
                          HUnit >=1.6.0 && < 1.7,
diff --git a/src/Backend/Python.hs b/src/Backend/Python.hs
--- a/src/Backend/Python.hs
+++ b/src/Backend/Python.hs
@@ -243,6 +243,7 @@
         Schema.VObject _ -> True
         Schema.VArray (Schema.VObject _) -> True
         Schema.VReference _ -> True
+        Schema.VArray (Schema.VReference _) -> True
         _ -> False
    in (pyType, isModel, nested)
 
diff --git a/src/Backend/Rust.hs b/src/Backend/Rust.hs
--- a/src/Backend/Rust.hs
+++ b/src/Backend/Rust.hs
@@ -452,6 +452,13 @@
 generateTableStruct table =
   let className = toPascalCase (Schema.tableName table) ++ "Doc"
       (fieldLines, nestedFromFields) = unzip $ map (generateField className) (Schema.tableFields table)
+      allFields =
+        [ ("_id", Schema.VId (toPascalCase (Schema.tableName table))),
+          ("_creation_time", Schema.VFloat64)
+        ]
+          ++ map (\f -> (Schema.fieldName f, Schema.fieldType f)) (Schema.tableFields table)
+      fromBlock = generateFromConvexValueImpl (className) allFields
+      toBlock = generateToConvexValueImpl (className) allFields
    in ( unlines
           [ "#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq)]",
             ("pub struct " ++ className ++ " {"),
@@ -459,9 +466,13 @@
             indent 1 ("pub _id: Id<" ++ className ++ ">,"),
             indent 1 "#[serde(default)]",
             indent 1 "#[serde(rename = \"_creationTime\")]",
-            indent 1 "pub creation_time: f64,",
+            indent 1 "pub _creation_time: f64,",
             unlines fieldLines,
-            "}"
+            "}",
+            "",
+            fromBlock,
+            "",
+            toBlock
           ],
         concat nestedFromFields
       )
@@ -723,7 +734,7 @@
       getterName = "get_" ++ fieldNameSnake
       (fieldTypeStr, _) = case stripPrefix (reverse "Object") (reverse structName) of
         Just cleanStructName -> toRustType (reverse cleanStructName ++ capitalize fieldName) fieldType
-        Nothing -> error "Expected struct name to end with 'Object' for optional field"
+        Nothing -> toRustType (structName ++ capitalize fieldName) fieldType
    in unlines
         [ indent 0 $ "fn " ++ getterName ++ "(map: &BTreeMap<String, Value>, key: &str) -> Result<Option<" ++ fieldTypeStr ++ ">, ApiError> {",
           indent 1 $ "match map.get(key) {",
@@ -740,7 +751,7 @@
       getterName = "get_" ++ fieldNameSnake
       (fieldTypeStr, _) = case stripPrefix (reverse "Object") (reverse structName) of
         Just cleanStructName -> toRustType (reverse cleanStructName ++ capitalize fieldName) fieldType
-        Nothing -> error "Expected struct name to end with 'Object' for optional field"
+        Nothing -> toRustType (structName ++ capitalize fieldName) fieldType
    in unlines
         [ indent 0 $ "fn " ++ getterName ++ "(map: &BTreeMap<String, Value>, key: &str) -> Result<" ++ fieldTypeStr ++ ", ApiError> {",
           indent 1 $ "match map.get(key) {",
diff --git a/src/Convex/Parser.hs b/src/Convex/Parser.hs
--- a/src/Convex/Parser.hs
+++ b/src/Convex/Parser.hs
@@ -1,11 +1,13 @@
+{-# LANGUAGE LambdaCase #-}
 {-# LANGUAGE OverloadedStrings #-}
 
-module Convex.Parser (parseProject, ParsedProject (..), apiFileParser) where
+module Convex.Parser (parseProject, parseProjectFromContents, ParsedProject (..), apiFileParser, runUnificationPass) where
 
 import Control.Monad (forM, void)
 import qualified Convex.Action.Parser as Action
 import qualified Convex.Schema.Parser as Schema
-import Data.List (sort)
+import Data.Char (toUpper)
+import Data.List (sort, sortOn)
 import qualified Data.Map as Map
 import System.FilePath (replaceExtension, (</>))
 import Text.Parsec
@@ -53,44 +55,45 @@
       void $ apiIdentifier -- Consume the alias, we don't need it
       return path
 
-parseProject :: FilePath -> FilePath -> IO (Either String ParsedProject)
-parseProject schemaPath declRootDir = do
-  -- Parse the source schema file first to get tables and the initial state with constants.
-  schemaContent <- readFile schemaPath
+parseProjectFromContents ::
+  String ->
+  String ->
+  [(String, String)] ->
+  IO (Either String ParsedProject)
+parseProjectFromContents schemaContent apiFileContent actionContents = do
+  -- 1. Parse Schema
   schemaResult <- Schema.parseSchema schemaContent
-
   case schemaResult of
     Left err -> return $ Left ("Failed to parse schema.ts: " ++ show err)
     Right schemaFile -> do
-      -- Re-construct the initial state for the action parser from the parsed constants.
       let initialState = Schema.ParserState {Schema.psConstants = Schema.parsedConstants schemaFile}
 
-      -- Parse the _generated/api.d.ts file to discover function modules.
-      let apiFilePath = declRootDir </> "_generated" </> "api.d.ts"
-      apiFileContent <- readFile apiFilePath
-      let modulePaths = case parse apiFileParser apiFilePath apiFileContent of
-            Left _ -> []
+      -- Parse API file to get module paths
+      let modulePaths = case parse apiFileParser "(api.d.ts)" apiFileContent of
+            Left _ -> [] -- Or should this be an error? The original returns []
             Right paths -> filter (/= "schema") paths
 
-      putStrLn $ "Found " ++ show (length modulePaths) ++ " action modules in: " ++ apiFilePath
+      let actionContentMap = Map.fromList actionContents
 
-      -- For each discovered module, parse its corresponding .d.ts file.
-      allFunctions <- fmap concat $ forM modulePaths $ \modulePath -> do
-        let fullPath = declRootDir </> replaceExtension modulePath ".d.ts"
+      -- Parse action files
+      allFunctions <- fmap concat . forM modulePaths $ \modulePath -> do
         let astPath = replaceExtension modulePath ""
-
-        actionContent <- readFile fullPath
-        actionResult <- runParserT (Action.parseActionFile astPath) initialState fullPath actionContent
-
-        case actionResult of
-          Left err -> do
-            putStrLn $ "Failed to parse actions from: " ++ fullPath ++ " | Error: " ++ show err
+        case Map.lookup modulePath actionContentMap of
+          Nothing -> do
+            putStrLn $ "Action content not found for module: " ++ modulePath
             return []
-          Right funcs -> do
-            putStrLn $ "Parsed actions from: " ++ fullPath
-            putStrLn $ show (length funcs) ++ " functions found"
-            return funcs
+          Just actionContent -> do
+            actionResult <- runParserT (Action.parseActionFile astPath) initialState modulePath actionContent
+            case actionResult of
+              Left err -> do
+                putStrLn $ "Failed to parse actions from: " ++ modulePath ++ " | Error: " ++ show err
+                return []
+              Right funcs -> do
+                putStrLn $ "Parsed actions from: " ++ modulePath
+                putStrLn $ show (length funcs) ++ " functions found"
+                return funcs
 
+      -- Construct and unify project
       let project =
             ParsedProject
               { ppSchema = Schema.parsedSchema schemaFile,
@@ -98,49 +101,152 @@
                 ppFunctions = allFunctions
               }
 
-      return $ Right . unifyProjectTypes $ project
+      return . Right . runUnificationPass $ project
 
+parseProject :: FilePath -> FilePath -> IO (Either String ParsedProject)
+parseProject schemaPath declRootDir = do
+  -- Parse the source schema file first to get tables and the initial state with constants.
+  schemaContent <- readFile schemaPath
+  -- Parse the _generated/api.d.ts file to discover function modules.
+  let apiFilePath = declRootDir </> "_generated" </> "api.d.ts"
+  apiFileContent <- readFile apiFilePath
+  let modulePaths = case parse apiFileParser apiFilePath apiFileContent of
+        Left _ -> []
+        Right paths -> filter (/= "schema") paths
+
+  putStrLn $ "Found " ++ show (length modulePaths) ++ " action modules in: " ++ apiFilePath
+
+  -- For each discovered module, parse its corresponding .d.ts file.
+  actionContents <- forM modulePaths $ \modulePath -> do
+    let fullPath = declRootDir </> replaceExtension modulePath ".d.ts"
+    content <- readFile fullPath
+    return (modulePath, content)
+
+  parseProjectFromContents schemaContent apiFileContent actionContents
+
 type UnionSignatureMap = Map.Map [String] String
 
--- | Pre-processes the parsed project to replace anonymous unions with named references
--- if they structurally match.
-unifyProjectTypes :: ParsedProject -> ParsedProject
-unifyProjectTypes project =
-  let unionMap = buildUnionSignatureMap (ppConstants project)
-   in project {ppFunctions = map (unifyFunctionTypes unionMap) (ppFunctions project)}
+type ObjectSignatureMap = Map.Map [(String, Schema.ConvexType)] String
+
+-- | Pre-processes the parsed project to replace anonymous unions and objects
+-- with named references if they structurally match. This is done iteratively
+-- to a fixed point to handle nested structures.
+runUnificationPass :: ParsedProject -> ParsedProject
+runUnificationPass project =
+  let ephemeralProject = addTableDocsToConstants project
+      unifiedProject = go ephemeralProject
+   in unifiedProject {ppConstants = ppConstants project} -- Discard ephemeral constants
   where
-    buildUnionSignatureMap :: Map.Map String Schema.ConvexType -> UnionSignatureMap
-    buildUnionSignatureMap constants =
-      Map.fromList
-        [ (sort $ map Schema.getLiteralString literals, name)
-        | (name, Schema.VUnion literals) <- Map.toList constants,
-          all Schema.isLiteral literals
-        ]
+    go currentProject =
+      let nextProject = unifyOnce currentProject
+       in if nextProject == currentProject
+            then currentProject
+            else go nextProject
 
-    unifyFunctionTypes :: UnionSignatureMap -> Action.ConvexFunction -> Action.ConvexFunction
-    unifyFunctionTypes unionMap func =
-      func
-        { Action.funcArgs = map (unifyArgType unionMap) (Action.funcArgs func),
-          Action.funcReturn = unifyTypeRecursively unionMap (Action.funcReturn func)
-        }
+canonicalizeType :: Schema.ConvexType -> Schema.ConvexType
+canonicalizeType (Schema.VObject fields) =
+  Schema.VObject . sortOn fst . map (\(n, t) -> (n, canonicalizeType t)) $ fields
+canonicalizeType (Schema.VUnion types) =
+  Schema.VUnion . sort . map canonicalizeType $ types
+canonicalizeType (Schema.VArray t) = Schema.VArray (canonicalizeType t)
+canonicalizeType (Schema.VOptional t) = Schema.VOptional (canonicalizeType t)
+canonicalizeType other = other
 
-    unifyArgType :: UnionSignatureMap -> (String, Schema.ConvexType) -> (String, Schema.ConvexType)
-    unifyArgType unionMap (argName, argType) =
-      (argName, unifyTypeRecursively unionMap argType)
+addTableDocsToConstants :: ParsedProject -> ParsedProject
+addTableDocsToConstants project =
+  let tableDocs =
+        Map.fromList
+          [ ( toPascalCase (Schema.tableName table) ++ "Doc",
+              Schema.VObject . sortOn fst $
+                [("_id", Schema.VId (Schema.tableName table)), ("_creationTime", Schema.VNumber)]
+                  ++ map
+                    (\f -> (Schema.fieldName f, Schema.fieldType f))
+                    (Schema.tableFields table)
+            )
+          | table <- Schema.getTables (ppSchema project)
+          ]
+      allConstants = Map.union (ppConstants project) tableDocs
+   in project {ppConstants = allConstants}
 
-    -- This new recursive function traverses the entire type structure.
-    unifyTypeRecursively :: UnionSignatureMap -> Schema.ConvexType -> Schema.ConvexType
-    unifyTypeRecursively unionMap u@(Schema.VUnion literals)
+toPascalCase :: String -> String
+toPascalCase [] = []
+toPascalCase (h : t) = toUpper h : t
+
+buildUnionSignatureMap :: Map.Map String Schema.ConvexType -> UnionSignatureMap
+buildUnionSignatureMap constants =
+  Map.fromList
+    [ (sort $ map Schema.getLiteralString literals, name)
+    | (name, Schema.VUnion literals) <- Map.toList constants,
+      all Schema.isLiteral literals
+    ]
+
+buildObjectSignatureMap :: Map.Map String Schema.ConvexType -> ObjectSignatureMap
+buildObjectSignatureMap constants =
+  Map.fromList
+    [ (sortOn fst fields, name)
+    | (name, Schema.VObject fields) <- Map.toList constants
+    ]
+
+unifyFunctionTypes :: (Schema.ConvexType -> Schema.ConvexType) -> Action.ConvexFunction -> Action.ConvexFunction
+unifyFunctionTypes unifyType func =
+  func
+    { Action.funcArgs = map (\(name, t) -> (name, unifyType t)) (Action.funcArgs func),
+      Action.funcReturn = unifyType (Action.funcReturn func)
+    }
+
+unifyTypeRecursively :: Maybe String -> UnionSignatureMap -> ObjectSignatureMap -> Schema.ConvexType -> Schema.ConvexType
+unifyTypeRecursively mCurrentName unionMap objectMap = go
+  where
+    goRec = unifyTypeRecursively Nothing unionMap objectMap
+
+    go u@(Schema.VUnion literals)
       | all Schema.isLiteral literals =
           let signature = sort $ map Schema.getLiteralString literals
            in case Map.lookup signature unionMap of
-                Just refName -> Schema.VReference refName
-                Nothing -> u
-      | otherwise = u
-    unifyTypeRecursively unionMap (Schema.VObject fields) =
-      Schema.VObject $ map (\(name, t) -> (name, unifyTypeRecursively unionMap t)) fields
-    unifyTypeRecursively unionMap (Schema.VArray inner) =
-      Schema.VArray $ unifyTypeRecursively unionMap inner
-    unifyTypeRecursively unionMap (Schema.VOptional inner) =
-      Schema.VOptional $ unifyTypeRecursively unionMap inner
-    unifyTypeRecursively _ otherType = otherType -- Base cases
+                Just refName ->
+                  if Just refName == mCurrentName
+                    then canonicalizeType u
+                    else Schema.VReference refName
+                Nothing -> canonicalizeType u
+      | otherwise =
+          let unifiedUnion = Schema.VUnion (map goRec literals)
+           in canonicalizeType unifiedUnion
+    go (Schema.VObject fields) =
+      let unifiedFields = map (\(name, t) -> (name, goRec t)) fields
+          canonicalAttempt = canonicalizeType (Schema.VObject unifiedFields)
+       in case canonicalAttempt of
+            Schema.VObject signature ->
+              let canonicalObject = Schema.VObject signature
+               in case Map.lookup signature objectMap of
+                    Just refName ->
+                      if Just refName == mCurrentName
+                        then canonicalObject
+                        else Schema.VReference refName
+                    Nothing -> canonicalObject
+            _ -> canonicalAttempt -- Should not happen, but safer
+    go (Schema.VArray inner) = Schema.VArray (goRec inner)
+    go (Schema.VOptional inner) = Schema.VOptional (goRec inner)
+    go otherType = otherType
+
+unifyOnce :: ParsedProject -> ParsedProject
+unifyOnce project =
+  let unionMap = buildUnionSignatureMap (ppConstants project)
+      initialObjectMap = buildObjectSignatureMap (ppConstants project)
+
+      -- First, unify the constants themselves. This resolves nested anonymous objects
+      -- within the constants first, creating a canonical representation for this pass.
+      unifiedConstants =
+        Map.mapWithKey (\k -> unifyTypeRecursively (Just k) unionMap initialObjectMap) (ppConstants project)
+
+      -- Now, build the object map for function unification from these *newly unified* constants.
+      -- This map contains the canonical object structures for this pass.
+      finalObjectMap = buildObjectSignatureMap unifiedConstants
+
+      -- Create the unification function for anonymous types found in function signatures.
+      unifyAnonType = unifyTypeRecursively Nothing unionMap finalObjectMap
+
+      unifiedFunctions = map (unifyFunctionTypes unifyAnonType) (ppFunctions project)
+   in project
+        { ppConstants = unifiedConstants,
+          ppFunctions = unifiedFunctions
+        }
diff --git a/test/ActionParserTest.hs b/test/ActionParserTest.hs
--- a/test/ActionParserTest.hs
+++ b/test/ActionParserTest.hs
@@ -3,10 +3,65 @@
 module ActionParserTest (tests) where
 
 import qualified Convex.Action.Parser as Action
+import qualified Convex.Parser as Parser
 import qualified Convex.Schema.Parser as Schema
+import qualified Data.Map as Map
 import Test.HUnit
 import Text.Parsec (runParserT)
 
+-- A dedicated test runner for unification tests that first parses a schema.
+runSchemaUnificationTest ::
+  String ->
+  String ->
+  String ->
+  [Action.ConvexFunction] ->
+  Test
+runSchemaUnificationTest testName schemaString actionString expected =
+  testName ~: TestCase $ do
+    -- First, parse the schema to get the constants.
+    schemaResult <- Schema.parseSchema schemaString
+    case schemaResult of
+      Left err -> assertFailure ("Schema parser failed: " ++ show err)
+      Right parsedSchemaFile -> do
+        let initialState = Schema.ParserState {Schema.psConstants = Schema.parsedConstants parsedSchemaFile}
+        -- Now, parse the action file with the constants from the schema.
+        actionResult <- runParserT (Action.parseActionFile "testPath") initialState "(test)" actionString
+        case actionResult of
+          Left err -> assertFailure ("Action parser failed: " ++ show err)
+          Right funcs ->
+            let project =
+                  Parser.ParsedProject
+                    { Parser.ppSchema = Schema.parsedSchema parsedSchemaFile,
+                      Parser.ppConstants = Schema.parsedConstants parsedSchemaFile,
+                      Parser.ppFunctions = funcs
+                    }
+                unifiedProject = Parser.runUnificationPass project
+             in Parser.ppFunctions unifiedProject @?= expected
+
+-- A dedicated test runner for unification tests.
+runUnificationTest ::
+  String ->
+  String ->
+  Map.Map String Schema.ConvexType ->
+  String ->
+  [Action.ConvexFunction] ->
+  Test
+runUnificationTest testName path constants input expected =
+  testName ~: TestCase $ do
+    let initialState = Schema.ParserState {Schema.psConstants = constants}
+    result <- runParserT (Action.parseActionFile path) initialState "(test)" input
+    case result of
+      Left err -> assertFailure ("Parser failed: " ++ show err)
+      Right funcs ->
+        let project =
+              Parser.ParsedProject
+                { Parser.ppSchema = Schema.Schema {Schema.getTables = []},
+                  Parser.ppConstants = constants,
+                  Parser.ppFunctions = funcs
+                }
+            unifiedProject = Parser.runUnificationPass project
+         in Parser.ppFunctions unifiedProject @?= expected
+
 tests :: Test
 tests =
   "Action Definition Parser"
@@ -18,7 +73,18 @@
         runActionTest "parses internal action with external type as VAny" "functions/stripe" sampleStripeSubscriptionAction expectedStripeSubscriptionAction,
         runActionTest "parses public action with external type" "functions/stripe" sampleStripeCheckoutAction sampleStripeCheckoutActionExpected,
         runActionTest "parses public action with external type as VAny" "functions/stripe" sampleStripeSubscriptionActionPublic expectedStripeSubscriptionActionPublic,
-        runActionTest "parses createAsset mutation with complex args" "functions/assets" sampleCreateAssetAction expectedCreateAssetAction
+        runActionTest "parses createAsset mutation with complex args" "functions/assets" sampleCreateAssetAction expectedCreateAssetAction,
+        runUnificationTest
+          "unifies function return with named doc"
+          "functions/users"
+          userProfileConstants
+          sampleGetUserAction
+          expectedGetUserAction,
+        runSchemaUnificationTest
+          "unifies function return with table doc"
+          sampleSchemaWithUserTable
+          sampleGetUserTableAction
+          expectedGetUserTableAction
       ]
   where
     runActionTest testName path input expected =
@@ -204,5 +270,141 @@
             )
           ],
         Action.funcReturn = Schema.VId "assets"
+      }
+  ]
+
+-- Test case for structural unification of a function's return type.
+userProfileConstants :: Map.Map String Schema.ConvexType
+userProfileConstants =
+  Map.fromList
+    [ ("UserProfile", userProfileType),
+      ("Address", addressType)
+    ]
+  where
+    addressType =
+      Schema.VObject
+        [ ("street", Schema.VString),
+          ("city", Schema.VString)
+        ]
+    userProfileType =
+      Schema.VObject
+        [ ("name", Schema.VString),
+          ("address", Schema.VReference "Address")
+        ]
+
+sampleGetUserAction :: String
+sampleGetUserAction =
+  unlines
+    [ "export declare const getUser: import(\"convex/server\").RegisteredQuery<\"public\", {}, Promise<{",
+      "  name: string;",
+      "  address: {",
+      "    street: string;",
+      "    city: string;",
+      "  };",
+      "}>>;"
+    ]
+
+expectedGetUserAction :: [Action.ConvexFunction]
+expectedGetUserAction =
+  [ Action.ConvexFunction
+      { Action.funcName = "getUser",
+        Action.funcPath = "functions/users",
+        Action.funcType = Action.Query,
+        Action.funcArgs = [],
+        Action.funcReturn = Schema.VReference "UserProfile" -- Should be unified
+      }
+  ]
+
+-- Test case for unification against a table doc.
+sampleSchemaWithUserTable :: String
+sampleSchemaWithUserTable =
+  unlines
+    [ "import { defineSchema, defineTable } from \"convex/server\";",
+      "import { v } from \"convex/values\";",
+      "",
+      "export default defineSchema({",
+      "  users: defineTable({",
+      "    name: v.string(),",
+      "    email: v.string()",
+      "  })",
+      "});"
+    ]
+
+sampleGetUserTableAction :: String
+sampleGetUserTableAction =
+  unlines
+    [ "export declare const getUser: import(\"convex/server\").RegisteredQuery<\"public\", {}, Promise<{",
+      "  _id: import(\"convex/values\").GenericId<\"users\">;",
+      "  _creationTime: number;",
+      "  name: string;",
+      "  email: string;",
+      "}>>;"
+    ]
+
+expectedGetUserTableAction :: [Action.ConvexFunction]
+expectedGetUserTableAction =
+  [ Action.ConvexFunction
+      { Action.funcName = "getUser",
+        Action.funcPath = "testPath",
+        Action.funcType = Action.Query,
+        Action.funcArgs = [],
+        Action.funcReturn = Schema.VReference "UsersDoc" -- Should be unified
+      }
+  ]
+
+sampleSchemaWithAssetsTable :: String
+sampleSchemaWithAssetsTable =
+  unlines
+    [ "import { defineSchema, defineTable } from \"convex/server\";",
+      "import { v } from \"convex/values\";",
+      "",
+      "export default defineSchema({",
+      "  projects: defineTable({",
+      "    name: v.string()",
+      "  }),",
+      "  assets: defineTable({",
+      "    project_id: v.id(\"projects\"),",
+      "    asset_name: v.string(),",
+      "    asset_essence_mtime: v.number(),",
+      "    link_metadata: v.object({",
+      "      sample_rate: v.number(),",
+      "      summary: v.bytes(),",
+      "      length: v.number()",
+      "    })",
+      "  })",
+      "});"
+    ]
+
+sampleGetAssetsAction :: String
+sampleGetAssetsAction =
+  unlines
+    [ "export declare const getAssets: import(\"convex/server\").RegisteredQuery<\"public\", {",
+      "  project_id: import(\"convex/values\").Id<\"projects\">;",
+      "  secret: string;",
+      "}, Promise<Array<{",
+      "  _id: import(\"convex/values\").Id<\"assets\">;",
+      "  _creationTime: number;",
+      "  project_id: import(\"convex/values\").Id<\"projects\">;",
+      "  asset_name: string;",
+      "  asset_essence_mtime: number;",
+      "  link_metadata: {",
+      "    summary: Uint8Array;",
+      "    sample_rate: number;",
+      "    length: number;",
+      "  };",
+      "}>>>;"
+    ]
+
+expectedGetAssetsAction :: [Action.ConvexFunction]
+expectedGetAssetsAction =
+  [ Action.ConvexFunction
+      { Action.funcName = "getAssets",
+        Action.funcPath = "testPath",
+        Action.funcType = Action.Query,
+        Action.funcArgs =
+          [ ("project_id", Schema.VId "projects"),
+            ("secret", Schema.VString)
+          ],
+        Action.funcReturn = Schema.VArray (Schema.VReference "AssetsDoc")
       }
   ]
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -4,6 +4,7 @@
 import qualified ApiParserTest
 import qualified RustSerializationTest
 import qualified SchemaParserTest
+import qualified UnificationTest
 import System.Exit (exitFailure, exitSuccess)
 import Test.HUnit
 
@@ -15,7 +16,8 @@
           [ ApiParserTest.tests,
             ActionParserTest.tests,
             SchemaParserTest.tests,
-            RustSerializationTest.tests
+            RustSerializationTest.tests,
+            UnificationTest.tests
           ]
 
   -- Run the combined test suite
diff --git a/test/UnificationTest.hs b/test/UnificationTest.hs
new file mode 100644
--- /dev/null
+++ b/test/UnificationTest.hs
@@ -0,0 +1,237 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module UnificationTest (tests) where
+
+import qualified Convex.Action.Parser as Action
+import qualified Convex.Parser as Parser
+import qualified Convex.Schema.Parser as Schema
+import Test.HUnit
+
+testUnifyWithTableDoc :: Test
+testUnifyWithTableDoc = "unifies function return with table doc" ~: TestCase $ do
+  let schemaContent =
+        unlines
+          [ "import { defineSchema, defineTable } from \"convex/server\";",
+            "import { v } from \"convex/values\";",
+            "",
+            "export default defineSchema({",
+            "  users: defineTable({",
+            "    name: v.string(),",
+            "    email: v.string()",
+            "  })",
+            "});"
+          ]
+  let apiFileContent =
+        unlines
+          [ "declare const fullApi: ApiFromModules<{",
+            "  \"myActions\": typeof myActions;",
+            "}>;"
+          ]
+  let actionContent =
+        unlines
+          [ "export declare const getUser: import(\"convex/server\").RegisteredQuery<\"public\", {}, Promise<{ _id: import(\"convex/values\").GenericId<\"users\">; _creationTime: number; name: string; email: string; }>>;"
+          ]
+  let actionContents = [("myActions", actionContent)]
+
+  result <- Parser.parseProjectFromContents schemaContent apiFileContent actionContents
+  case result of
+    Left err -> assertFailure ("Parser failed: " ++ show err)
+    Right project ->
+      let expected =
+            [ Action.ConvexFunction
+                { Action.funcName = "getUser",
+                  Action.funcPath = "myActions",
+                  Action.funcType = Action.Query,
+                  Action.funcArgs = [],
+                  Action.funcReturn = Schema.VReference "UsersDoc"
+                }
+            ]
+       in Parser.ppFunctions project @?= expected
+
+testUnifyWithNestedObject :: Test
+testUnifyWithNestedObject = "unifies function return with nested object" ~: TestCase $ do
+  let schemaContent =
+        unlines
+          [ "import { defineSchema, defineTable } from \"convex/server\";",
+            "import { v } from \"convex/values\";",
+            "",
+            "export default defineSchema({",
+            "  users: defineTable({",
+            "    name: v.string(),",
+            "    profile: v.object({",
+            "      image: v.string()",
+            "    })",
+            "  })",
+            "});"
+          ]
+  let apiFileContent =
+        unlines
+          [ "declare const fullApi: ApiFromModules<{",
+            "  \"myActions\": typeof myActions;",
+            "}>;"
+          ]
+  let actionContent =
+        unlines
+          [ "export declare const getUser: import(\"convex/server\").RegisteredQuery<\"public\", {}, Promise<{ _id: import(\"convex/values\").GenericId<\"users\">; _creationTime: number; name: string; profile: { image: string; }; }>>;"
+          ]
+  let actionContents = [("myActions", actionContent)]
+
+  result <- Parser.parseProjectFromContents schemaContent apiFileContent actionContents
+  case result of
+    Left err -> assertFailure ("Parser failed: " ++ show err)
+    Right project ->
+      let expected =
+            [ Action.ConvexFunction
+                { Action.funcName = "getUser",
+                  Action.funcPath = "myActions",
+                  Action.funcType = Action.Query,
+                  Action.funcArgs = [],
+                  Action.funcReturn = Schema.VReference "UsersDoc"
+                }
+            ]
+       in Parser.ppFunctions project @?= expected
+
+testUnifyWithArrayOfDocs :: Test
+testUnifyWithArrayOfDocs = "unifies function returning an array of docs" ~: TestCase $ do
+  let schemaContent =
+        unlines
+          [ "import { defineSchema, defineTable } from \"convex/server\";",
+            "import { v } from \"convex/values\";",
+            "",
+            "export default defineSchema({",
+            "  users: defineTable({",
+            "    name: v.string(),",
+            "    email: v.string()",
+            "  })",
+            "});"
+          ]
+  let apiFileContent =
+        unlines
+          [ "declare const fullApi: ApiFromModules<{",
+            "  \"myActions\": typeof myActions;",
+            "}>;"
+          ]
+  let actionContent =
+        unlines
+          [ "export declare const listUsers: import(\"convex/server\").RegisteredQuery<\"public\", {}, Promise<{",
+            "  _id: import(\"convex/values\").GenericId<\"users\">;",
+            "  _creationTime: number;",
+            "  name: string;",
+            "  email: string;",
+            "}[]>>;"
+          ]
+  let actionContents = [("myActions", actionContent)]
+
+  result <- Parser.parseProjectFromContents schemaContent apiFileContent actionContents
+  case result of
+    Left err -> assertFailure ("Parser failed: " ++ show err)
+    Right project ->
+      let expected =
+            [ Action.ConvexFunction
+                { Action.funcName = "listUsers",
+                  Action.funcPath = "myActions",
+                  Action.funcType = Action.Query,
+                  Action.funcArgs = [],
+                  Action.funcReturn = Schema.VArray (Schema.VReference "UsersDoc")
+                }
+            ]
+       in Parser.ppFunctions project @?= expected
+
+testUnifyWithDeeplyNestedObject :: Test
+testUnifyWithDeeplyNestedObject = "unifies function return with deeply nested object" ~: TestCase $ do
+  let schemaContent =
+        unlines
+          [ "import { defineSchema, defineTable } from \"convex/server\";",
+            "import { v } from \"convex/values\";",
+            "",
+            "export default defineSchema({",
+            "  users: defineTable({",
+            "    name: v.string(),",
+            "    profile: v.object({",
+            "      image: v.string(),",
+            "      details: v.object({",
+            "        bio: v.string()",
+            "      })",
+            "    })",
+            "  })",
+            "});"
+          ]
+  let apiFileContent =
+        unlines
+          [ "declare const fullApi: ApiFromModules<{",
+            "  \"myActions\": typeof myActions;",
+            "}>;"
+          ]
+  let actionContent =
+        unlines
+          [ "export declare const getUser: import(\"convex/server\").RegisteredQuery<\"public\", {}, Promise<{ _id: import(\"convex/values\").GenericId<\"users\">; _creationTime: number; name: string; profile: { image: string; details: { bio: string; } }; }>>;"
+          ]
+  let actionContents = [("myActions", actionContent)]
+
+  result <- Parser.parseProjectFromContents schemaContent apiFileContent actionContents
+  case result of
+    Left err -> assertFailure ("Parser failed: " ++ show err)
+    Right project ->
+      let expected =
+            [ Action.ConvexFunction
+                { Action.funcName = "getUser",
+                  Action.funcPath = "myActions",
+                  Action.funcType = Action.Query,
+                  Action.funcArgs = [],
+                  Action.funcReturn = Schema.VReference "UsersDoc"
+                }
+            ]
+       in Parser.ppFunctions project @?= expected
+
+tests :: Test
+tests =
+  "Unification"
+    ~: test
+      [ testUnifyWithTableDoc,
+        testUnifyWithNestedObject,
+        testUnifyWithArrayOfDocs,
+        testUnifyWithDeeplyNestedObject,
+        testUnifyWithShuffledFields
+      ]
+
+testUnifyWithShuffledFields :: Test
+testUnifyWithShuffledFields = "unifies function return with shuffled fields" ~: TestCase $ do
+  let schemaContent =
+        unlines
+          [ "import { defineSchema, defineTable } from \"convex/server\";",
+            "import { v } from \"convex/values\";",
+            "",
+            "export default defineSchema({",
+            "  users: defineTable({",
+            "    name: v.string(),",
+            "    email: v.string()",
+            "  })",
+            "});"
+          ]
+  let apiFileContent =
+        unlines
+          [ "declare const fullApi: ApiFromModules<{",
+            "  \"myActions\": typeof myActions;",
+            "}>;"
+          ]
+  let actionContent =
+        unlines
+          -- Note the shuffled fields: email, name instead of name, email
+          [ "export declare const getUser: import(\"convex/server\").RegisteredQuery<\"public\", {}, Promise<{ _id: import(\"convex/values\").GenericId<\"users\">; _creationTime: number; email: string; name: string; }>>;"
+          ]
+  let actionContents = [("myActions", actionContent)]
+
+  result <- Parser.parseProjectFromContents schemaContent apiFileContent actionContents
+  case result of
+    Left err -> assertFailure ("Parser failed: " ++ show err)
+    Right project ->
+      let expected =
+            [ Action.ConvexFunction
+                { Action.funcName = "getUser",
+                  Action.funcPath = "myActions",
+                  Action.funcType = Action.Query,
+                  Action.funcArgs = [],
+                  Action.funcReturn = Schema.VReference "UsersDoc"
+                }
+            ]
+       in Parser.ppFunctions project @?= expected
