Make partialObject recursively deep-partial

Amolith and Shelley created

partialObject() wrapped each property in Type.Optional() but did not
recurse into nested TObject properties. Adding a nested object to any
config section would require the full object in partial config files
rather than allowing a subset.

Fix: when a property has Kind === 'Object' with a properties field,
recursively apply partialObject before wrapping in Optional. Type.Record
(also type 'object' but uses patternProperties) is correctly excluded
via the Kind check.

Tests: 8 new cases using synthetic schemas to verify deep-partial
behavior — empty objects at every nesting level, partial inner fields,
type rejection inside nested objects, Type.Record passthrough, and
3-level deep nesting.

Co-authored-by: Shelley <shelley@exe.dev>

Change summary

src/config/schema.ts           |  8 ++-
test/config-validation.test.ts | 78 ++++++++++++++++++++++++++++++++++++
2 files changed, 83 insertions(+), 3 deletions(-)

Detailed changes

src/config/schema.ts 🔗

@@ -1,4 +1,4 @@
-import { Type, type Static, type TObject, type TProperties } from "@sinclair/typebox";
+import { Type, Kind, type Static, type TObject, type TProperties } from "@sinclair/typebox";
 
 const CustomModelSchema = Type.Object({
   provider: Type.String(),
@@ -56,10 +56,12 @@ export const ConfigSchema = Type.Object({
 });
 
 /** Deep-partial version of ConfigSchema for validating TOML override files. */
-function partialObject<T extends TProperties>(schema: TObject<T>) {
+export function partialObject<T extends TProperties>(schema: TObject<T>) {
   const partial: Record<string, unknown> = {};
   for (const [key, value] of Object.entries(schema.properties)) {
-    partial[key] = Type.Optional(value as any);
+    const v = value as any;
+    const inner = v[Kind] === 'Object' && v.properties ? partialObject(v) : v;
+    partial[key] = Type.Optional(inner as any);
   }
   return Type.Object(partial as any);
 }

test/config-validation.test.ts 🔗

@@ -2,8 +2,11 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
 import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
+import { Type } from "@sinclair/typebox";
+import { Value } from "@sinclair/typebox/value";
 import { ConfigError } from "../src/util/errors.js";
 import { loadConfig } from "../src/config/loader.js";
+import { partialObject } from "../src/config/schema.js";
 
 describe("config validation", () => {
   let configDir: string;
@@ -126,3 +129,78 @@ describe("config validation", () => {
     }
   });
 });
+
+describe("partialObject deep-partial behavior", () => {
+  const NestedSchema = Type.Object({
+    name: Type.String(),
+    inner: Type.Object({
+      host: Type.String(),
+      port: Type.Number(),
+    }),
+  });
+
+  const PartialNested = partialObject(NestedSchema);
+
+  test("accepts empty object (all fields optional at every level)", () => {
+    const result = Value.Check(PartialNested, {});
+    expect(result).toBe(true);
+  });
+
+  test("accepts object with nested section present but inner fields omitted", () => {
+    const result = Value.Check(PartialNested, { inner: {} });
+    expect(result).toBe(true);
+  });
+
+  test("accepts object with partial inner fields of a nested object", () => {
+    const result = Value.Check(PartialNested, { inner: { host: "localhost" } });
+    expect(result).toBe(true);
+  });
+
+  test("accepts fully specified object", () => {
+    const result = Value.Check(PartialNested, {
+      name: "test",
+      inner: { host: "localhost", port: 8080 },
+    });
+    expect(result).toBe(true);
+  });
+
+  test("rejects wrong type inside nested object", () => {
+    const result = Value.Check(PartialNested, { inner: { port: "not-a-number" } });
+    expect(result).toBe(false);
+  });
+
+  test("rejects wrong type at top level", () => {
+    const result = Value.Check(PartialNested, { name: 123 });
+    expect(result).toBe(false);
+  });
+
+  test("does not recurse into Type.Record", () => {
+    const SchemaWithRecord = Type.Object({
+      headers: Type.Record(Type.String(), Type.String()),
+    });
+    const Partial = partialObject(SchemaWithRecord);
+    // Record should remain as-is (not turned into a partial object)
+    // Valid: omitted entirely
+    expect(Value.Check(Partial, {})).toBe(true);
+    // Valid: proper record
+    expect(Value.Check(Partial, { headers: { "x-key": "val" } })).toBe(true);
+    // Invalid: wrong value type in record
+    expect(Value.Check(Partial, { headers: { "x-key": 42 } })).toBe(false);
+  });
+
+  test("handles deeply nested objects (3 levels)", () => {
+    const DeepSchema = Type.Object({
+      level1: Type.Object({
+        level2: Type.Object({
+          value: Type.Number(),
+        }),
+      }),
+    });
+    const PartialDeep = partialObject(DeepSchema);
+    expect(Value.Check(PartialDeep, {})).toBe(true);
+    expect(Value.Check(PartialDeep, { level1: {} })).toBe(true);
+    expect(Value.Check(PartialDeep, { level1: { level2: {} } })).toBe(true);
+    expect(Value.Check(PartialDeep, { level1: { level2: { value: 42 } } })).toBe(true);
+    expect(Value.Check(PartialDeep, { level1: { level2: { value: "nope" } } })).toBe(false);
+  });
+});