Repository: alexmarqs/zod-config
Pull request #28: feat: lenient key matching
Author: login: dirkluijk
Created: 2025-04-28T13:48:04Z | Merged: 2025-05-02T16:18:58Z

Closes https://github.com/alexmarqs/zod-config/issues/22


Diff:
diff --git a/README.md b/README.md
index b2975b2..84f09dc 100644
--- a/README.md
+++ b/README.md
@@ -52,6 +52,7 @@ yarn add zod-config zod # yarn
 - [Callbacks](#callbacks)
 - [Custom Logger](#custom-logger)
 - [Silent mode](#silent-mode)
+- [Lenient key matching](#lenient-key-matching)
 - [Contributing notes](#contributing-notes)
 - [On the web](#on-the-web)
 
@@ -443,6 +444,32 @@ const config = await loadConfig({
 });
 ```
 
+### Lenient key matching
+
+If the source of your adapters uses a different casing or formatting compared to the schema you are using, you can enable the `lenientMatching` option. This is useful when working with environment variables that typically use UPPER_SNAKE_CASE or when integrating with different systems that use varying naming conventions. By default, the key matching is `strict`, meaning that the keys must match exactly.
+
+```ts
+import { z } from 'zod';
+import { loadConfig } from 'zod-config';
+import { envAdapter } from 'zod-config/env-adapter';
+
+const schemaConfig = z.object({
+  myHost: z.string(),
+});
+
+const config = await loadConfig({
+  schema: schemaConfig,
+  keyMatching: 'lenient',
+  adapters: envAdapter(),
+});
+```
+
+In this example:
+- The key `MYHOST`, `MY_HOST`, or `my-host` from the adapter would be correctly matched to `myHost` in your schema
+
+The lenient matching works by comparing keys after:
+1. Removing all non-alphanumeric characters (like underscores, hyphens, dots)
+2. Converting to lowercase
 
 ## Contributing notes
 
diff --git a/src/lib/adapters/directory-adapter/index.ts b/src/lib/adapters/directory-adapter/index.ts
index f781468..09edf8b 100644
--- a/src/lib/adapters/directory-adapter/index.ts
+++ b/src/lib/adapters/directory-adapter/index.ts
@@ -1,7 +1,7 @@
 import assert from "node:assert";
 import path from "node:path";
 
-import { deepMerge, filteredData } from "../utils";
+import { deepMerge, filteredData } from "../../utils";
 import type { Adapter, BaseAdapterProps } from "../../../types";
 import { getExtensionToAdapterFactoryMap, type AdapterSpecifier } from "./adapter-specifiers";
 import { getAllowedFilenames } from "./allowed-filenames";
diff --git a/src/lib/adapters/dotenv-adapter/index.ts b/src/lib/adapters/dotenv-adapter/index.ts
index ba81d23..dd899e0 100644
--- a/src/lib/adapters/dotenv-adapter/index.ts
+++ b/src/lib/adapters/dotenv-adapter/index.ts
@@ -1,7 +1,7 @@
 import { parse } from "dotenv";
 import { readFile } from "node:fs/promises";
 import type { Adapter, BaseAdapterProps } from "../../../types";
-import { filteredData } from "../utils";
+import { filteredData } from "../../utils";
 
 export type DotEnvAdapterProps = BaseAdapterProps & {
   path: string;
diff --git a/src/lib/adapters/env-adapter/index.ts b/src/lib/adapters/env-adapter/index.ts
index effa004..8bf27b1 100644
--- a/src/lib/adapters/env-adapter/index.ts
+++ b/src/lib/adapters/env-adapter/index.ts
@@ -1,5 +1,5 @@
 import type { Adapter, BaseAdapterProps } from "../../../types";
-import { filteredData } from "../utils";
+import { filteredData } from "../../utils";
 
 export type EnvAdapterProps = BaseAdapterProps & {
   customEnv?: Record<string, any>;
diff --git a/src/lib/adapters/json-adapter/index.ts b/src/lib/adapters/json-adapter/index.ts
index 91a51be..237638a 100644
--- a/src/lib/adapters/json-adapter/index.ts
+++ b/src/lib/adapters/json-adapter/index.ts
@@ -1,5 +1,5 @@
 import type { Adapter, BaseAdapterProps } from "../../../types";
-import { filteredData } from "../utils";
+import { filteredData } from "../../utils";
 import { readFile } from "node:fs/promises";
 
 export type JsonAdapterProps = BaseAdapterProps & {
diff --git a/src/lib/adapters/script-adapter/index.ts b/src/lib/adapters/script-adapter/index.ts
index 5a74c42..f3ee3a7 100644
--- a/src/lib/adapters/script-adapter/index.ts
+++ b/src/lib/adapters/script-adapter/index.ts
@@ -1,5 +1,5 @@
 import type { Adapter, BaseAdapterProps } from "../../../types";
-import { filteredData } from "../utils";
+import { filteredData } from "../../utils";
 
 export type ScriptAdapterProps = BaseAdapterProps & {
   path: string;
diff --git a/src/lib/adapters/toml-adapter/index.ts b/src/lib/adapters/toml-adapter/index.ts
index 57573e8..f318fac 100644
--- a/src/lib/adapters/toml-adapter/index.ts
+++ b/src/lib/adapters/toml-adapter/index.ts
@@ -1,5 +1,5 @@
 import type { Adapter, BaseAdapterProps } from "../../../types";
-import { filteredData } from "../utils";
+import { filteredData } from "../../utils";
 import { readFile } from "node:fs/promises";
 import { parse as tomlParse } from "smol-toml";
 
diff --git a/src/lib/adapters/utils.ts b/src/lib/adapters/utils.ts
deleted file mode 100644
index 80900e7..0000000
--- a/src/lib/adapters/utils.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-/**
- * @deprecated To be deprecated soon in favor of filterByRegex
- */
-export const filterByPrefixKey = (data: unknown, prefixKey: string) => {
-  if (data == null) return {};
-  if (!isMergeableObject(data))
-    throw new TypeError(`Cannot filter ${data} by prefix key as it is not a record-like object`);
-
-  return Object.keys(data)
-    .filter((key) => key.startsWith(prefixKey))
-    .reduce<Partial<Record<string, unknown>>>((acc, key) => {
-      acc[key] = data[key];
-
-      return acc;
-    }, {});
-};
-
-export const filterByRegex = (data: unknown, regex: RegExp) => {
-  if (data == null) return {};
-  if (!isMergeableObject(data))
-    throw new TypeError(`Cannot filter ${data} by regex as it is not a record-like object`);
-
-  return Object.keys(data)
-    .filter((key) => regex.test(key))
-    .reduce<Partial<Record<string, unknown>>>((acc, key) => {
-      acc[key] = data[key];
-
-      return acc;
-    }, {});
-};
-
-export function deepMerge(
-  target: Partial<Record<string, unknown>>,
-  ...sources: unknown[]
-): Partial<Record<string, unknown>> {
-  if (!sources.length) {
-    return target;
-  }
-
-  const source = sources.shift();
-
-  if (source === undefined) {
-    return target;
-  }
-
-  if (isMergeableObject(target) && isMergeableObject(source)) {
-    Object.keys(source).forEach((key) => {
-      if (source[key] === undefined) return;
-
-      if (!isMergeableObject(source[key])) {
-        target[key] = source[key];
-        return;
-      }
-
-      const subTarget = target[key];
-      if (!isMergeableObject(subTarget)) {
-        target[key] = deepMerge({}, source[key]);
-        return;
-      }
-
-      deepMerge(subTarget, source[key]);
-    });
-  }
-
-  return deepMerge(target, ...sources);
-}
-
-export function isMergeableObject(item: unknown): item is Partial<Record<string, unknown>> {
-  if (!item) return false;
-  if (typeof item !== "object") return false;
-  // ES6 class instances, Maps, Sets, Arrays, etc. are not considered records
-  if (Object.getPrototypeOf(item) === Object.prototype) return true;
-  // Some library/Node.js functions return records with null prototype
-  if (Object.getPrototypeOf(item) === null) return true;
-  return false;
-}
-
-/**
- * Filter the data based on the regex or prefix key (to be deprecated soon)
- */
-export function filteredData(
-  data: Record<string, unknown>,
-  options: { regex?: RegExp; prefixKey?: string },
-) {
-  const { regex, prefixKey } = options;
-
-  if (regex) {
-    return filterByRegex(data, regex);
-  }
-
-  if (prefixKey) {
-    return filterByPrefixKey(data, prefixKey);
-  }
-
-  return data;
-}
diff --git a/src/lib/adapters/yaml-adapter/index.ts b/src/lib/adapters/yaml-adapter/index.ts
index 44e2c84..70caa9b 100644
--- a/src/lib/adapters/yaml-adapter/index.ts
+++ b/src/lib/adapters/yaml-adapter/index.ts
@@ -1,5 +1,5 @@
 import type { Adapter, BaseAdapterProps } from "../../../types";
-import { filteredData } from "../utils";
+import { filteredData } from "../../utils";
 import { readFile } from "node:fs/promises";
 import YAML from "yaml";
 
diff --git a/src/lib/config.ts b/src/lib/config.ts
index 768a798..f58ace1 100644
--- a/src/lib/config.ts
+++ b/src/lib/config.ts
@@ -1,6 +1,6 @@
 import type { AnyZodObject, z } from "zod";
-import type { Adapter, Config, Logger } from "../types";
-import { deepMerge } from "./adapters/utils";
+import type { Adapter, Config, KeyMatching, Logger } from "../types";
+import { deepMerge, applyKeyMatching } from "./utils";
 
 /**
  * Load config from adapters.
@@ -21,6 +21,8 @@ export const loadConfig = async <T extends AnyZodObject>(
   const data = await getDataFromAdapters(
     Array.isArray(adapters) ? adapters : adapters ? [adapters] : [],
     logger,
+    config.schema,
+    config.keyMatching ?? "strict",
   );
 
   // Validate data against schema
@@ -45,7 +47,12 @@ export const loadConfig = async <T extends AnyZodObject>(
   return result.data;
 };
 
-const getDataFromAdapters = async (adapters: Adapter[], logger: Logger) => {
+const getDataFromAdapters = async (
+  adapters: Adapter[],
+  logger: Logger,
+  schema: AnyZodObject,
+  keyMatching: KeyMatching,
+) => {
   // If no adapters are provided, we will read from process.env
   if (!adapters || adapters.length === 0) {
     return process.env;
@@ -55,7 +62,9 @@ const getDataFromAdapters = async (adapters: Adapter[], logger: Logger) => {
   const promiseResult = await Promise.all(
     adapters.map(async (adapter) => {
       try {
-        return await adapter.read();
+        const data = await adapter.read();
+
+        return applyKeyMatching(data, schema.shape, keyMatching);
       } catch (error) {
         if (!adapter.silent) {
           logger.warn(
diff --git a/src/lib/utils/deep-merge.ts b/src/lib/utils/deep-merge.ts
new file mode 100644
index 0000000..5d6858f
--- /dev/null
+++ b/src/lib/utils/deep-merge.ts
@@ -0,0 +1,37 @@
+import { isMergeableObject } from '@/lib/utils/is-mergeable-object';
+
+export function deepMerge(
+    target: Partial<Record<string, unknown>>,
+    ...sources: unknown[]
+): Partial<Record<string, unknown>> {
+    if (!sources.length) {
+        return target;
+    }
+
+    const source = sources.shift();
+
+    if (source === undefined) {
+        return target;
+    }
+
+    if (isMergeableObject(target) && isMergeableObject(source)) {
+        Object.keys(source).forEach((key) => {
+            if (source[key] === undefined) return;
+
+            if (!isMergeableObject(source[key])) {
+                target[key] = source[key];
+                return;
+            }
+
+            const subTarget = target[key];
+            if (!isMergeableObject(subTarget)) {
+                target[key] = deepMerge({}, source[key]);
+                return;
+            }
+
+            deepMerge(subTarget, source[key]);
+        });
+    }
+
+    return deepMerge(target, ...sources);
+}
diff --git a/src/lib/utils/filtered-data.ts b/src/lib/utils/filtered-data.ts
new file mode 100644
index 0000000..29a679f
--- /dev/null
+++ b/src/lib/utils/filtered-data.ts
@@ -0,0 +1,52 @@
+import { isMergeableObject } from '@/lib/utils/is-mergeable-object';
+
+/**
+ * Filter the data based on the regex or prefix key (to be deprecated soon)
+ */
+export function filteredData(
+    data: Record<string, unknown>,
+    options: { regex?: RegExp; prefixKey?: string },
+) {
+    const { regex, prefixKey } = options;
+
+    if (regex) {
+        return filterByRegex(data, regex);
+    }
+
+    if (prefixKey) {
+        return filterByPrefixKey(data, prefixKey);
+    }
+
+    return data;
+}
+
+/**
+ * @deprecated To be deprecated soon in favor of filterByRegex
+ */
+export const filterByPrefixKey = (data: unknown, prefixKey: string) => {
+    if (data == null) return {};
+    if (!isMergeableObject(data))
+        throw new TypeError(`Cannot filter ${data} by prefix key as it is not a record-like object`);
+
+    return Object.keys(data)
+        .filter((key) => key.startsWith(prefixKey))
+        .reduce<Partial<Record<string, unknown>>>((acc, key) => {
+            acc[key] = data[key];
+
+            return acc;
+        }, {});
+};
+
+export const filterByRegex = (data: unknown, regex: RegExp) => {
+    if (data == null) return {};
+    if (!isMergeableObject(data))
+        throw new TypeError(`Cannot filter ${data} by regex as it is not a record-like object`);
+
+    return Object.keys(data)
+        .filter((key) => regex.test(key))
+        .reduce<Partial<Record<string, unknown>>>((acc, key) => {
+            acc[key] = data[key];
+
+            return acc;
+        }, {});
+};
diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts
new file mode 100644
index 0000000..c96ce90
--- /dev/null
+++ b/src/lib/utils/index.ts
@@ -0,0 +1,4 @@
+export * from './deep-merge';
+export * from './filtered-data';
+export * from './is-mergeable-object';
+export * from './key-matchers';
diff --git a/src/lib/utils/is-mergeable-object.ts b/src/lib/utils/is-mergeable-object.ts
new file mode 100644
index 0000000..d62d9c6
--- /dev/null
+++ b/src/lib/utils/is-mergeable-object.ts
@@ -0,0 +1,9 @@
+export function isMergeableObject(item: unknown): item is Partial<Record<string, unknown>> {
+    if (!item) return false;
+    if (typeof item !== "object") return false;
+    // ES6 class instances, Maps, Sets, Arrays, etc. are not considered records
+    if (Object.getPrototypeOf(item) === Object.prototype) return true;
+    // Some library/Node.js functions return records with null prototype
+    if (Object.getPrototypeOf(item) === null) return true;
+    return false;
+}
diff --git a/src/lib/utils/key-matchers.ts b/src/lib/utils/key-matchers.ts
new file mode 100644
index 0000000..1095d04
--- /dev/null
+++ b/src/lib/utils/key-matchers.ts
@@ -0,0 +1,44 @@
+import { type AnyZodObject, ZodObject, type ZodRawShape } from "zod";
+import { isMergeableObject } from "@/lib/utils/is-mergeable-object";
+import type { KeyMatching } from "@/types";
+
+type KeyMatcher = (valueKey: string, shapeKey: string) => boolean;
+
+export const KEY_MATCHERS: Record<KeyMatching, KeyMatcher> = {
+  strict: compareBy((it) => it),
+  lenient: compareBy(alphaNumericalLower),
+};
+
+/**
+ * Returns the data, with all the nested keys transformed to match the shape of the schema.
+ */
+export function applyKeyMatching(
+  data: Record<string, unknown>,
+  shape: ZodRawShape,
+  keyMatcher: keyof typeof KEY_MATCHERS,
+): Record<string, unknown> {
+  return Object.fromEntries(
+    Object.entries(data).map(([key, value]) => {
+      const matchedKey = Object.keys(shape).find((it) => KEY_MATCHERS[keyMatcher](it, key)) ?? key;
+
+      const zodType = shape[matchedKey];
+      if (isMergeableObject(value) && isZodObject(zodType)) {
+        return [matchedKey, applyKeyMatching(value, zodType.shape, keyMatcher)];
+      }
+
+      return [matchedKey, value];
+    }),
+  );
+}
+
+function isZodObject(input: unknown): input is AnyZodObject {
+  return input instanceof ZodObject;
+}
+
+function compareBy<T, R>(selector: (it: T) => R): (a: T, b: T) => boolean {
+  return (a: T, b: T) => selector(a) === selector(b);
+}
+
+function alphaNumericalLower(key: string) {
+  return key.replace(/[^a-z0-9]/gi, "").toLowerCase();
+}
diff --git a/src/types.ts b/src/types.ts
index e83c2e1..d4496e2 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -42,6 +42,10 @@ export type Config<T extends z.AnyZodObject = z.AnyZodObject> = {
    * Logger to use
    */
   logger?: Logger;
+  /**
+   * How to handle casing differences.
+   */
+  keyMatching?: KeyMatching;
 };
 
 /**
@@ -72,3 +76,5 @@ export type BaseAdapterProps = {
    */
   silent?: boolean;
 };
+
+export type KeyMatching = 'lenient' | 'strict'
diff --git a/tests/key-matching.test.ts b/tests/key-matching.test.ts
new file mode 100644
index 0000000..6772492
--- /dev/null
+++ b/tests/key-matching.test.ts
@@ -0,0 +1,141 @@
+import { describe, expect, it, vi } from "vitest";
+import { z } from "zod";
+
+import { loadConfig } from "@/index";
+
+describe("Lenient key matching tests", () => {
+  it("should perform lenient matching", async () => {
+    const MyConfig = z.object({
+      foo: z.object({
+        enabled: z.boolean().default(true),
+        nestedProp: z.string(),
+        OTHER_PROP: z.string(),
+      }),
+    });
+
+    const mockedLogger = { warn: vi.fn() };
+    const config = await loadConfig({
+      logger: mockedLogger,
+      schema: MyConfig,
+      keyMatching: "lenient",
+      adapters: [
+        inlineAdapter({
+          FOO: { NESTED_PROP: "Foo!", otherProp: "Test" },
+        }),
+      ],
+    });
+
+    expect(config).toEqual({
+      foo: {
+        enabled: true,
+        nestedProp: "Foo!",
+        OTHER_PROP: "Test",
+      },
+    });
+  });
+
+  it("key matching should preserve order of adapters", async () => {
+    const MyConfig = z.object({
+      foo: z.object({
+        enabled: z.boolean().default(true),
+        nestedProp: z.string(),
+        OTHER_PROP: z.string(),
+      }),
+    });
+
+    const mockedLogger = { warn: vi.fn() };
+    const config = await loadConfig({
+      logger: mockedLogger,
+      schema: MyConfig,
+      keyMatching: "lenient",
+      adapters: [
+        inlineAdapter({ FOO: { nestedProp: "Foo!", otherProp: "Test" } }),
+        inlineAdapter({ FOO: { NESTED_PROP: "Override1", otherProp: "Override1" } }),
+        inlineAdapter({ foo: { NESTED_PROP: "Override2", otherProp: "Override2" } }),
+        inlineAdapter({ FOO: { NESTED_PROP: "Override3" } }),
+      ],
+    });
+
+    expect(config).toEqual({
+      foo: {
+        enabled: true,
+        nestedProp: "Override3",
+        OTHER_PROP: "Override2",
+      },
+    });
+  });
+
+  it("should handle complex nested structures with various key formats", async () => {
+    const ComplexConfig = z.object({
+      apiSettings: z.object({
+        baseUrl: z.string(),
+        timeoutMs: z.number(),
+        retryCount: z.number(),
+        headers: z.record(z.string()),
+      }),
+      databaseConfig: z.object({
+        connectionString: z.string(),
+        poolSize: z.number(),
+        migrations: z.object({
+          enableAuto: z.boolean(),
+          scriptsPath: z.string(),
+        }),
+      }),
+      featureFlags: z.record(z.boolean()),
+    });
+
+    const config = await loadConfig({
+      schema: ComplexConfig,
+      keyMatching: "lenient",
+      adapters: [
+        inlineAdapter({
+          API_SETTINGS: {
+            "base-url": "https://api.example.com",
+            TIMEOUT_MS: 5000,
+            retry_count: 3,
+            headers: { "x-api-key": "test-key" },
+          },
+          database_config: {
+            ConnectionString: "postgres://localhost:5432/mydb",
+            "POOL-SIZE": 10,
+            MIGRATIONS: {
+              enable_auto: true,
+              "scripts.path": "/migrations",
+            },
+          },
+          "FEATURE.FLAGS": {
+            newUI: true,
+            BETA_FEATURES: false,
+            "experimental-api": true,
+          },
+        }),
+      ],
+    });
+
+    expect(config).toEqual({
+      apiSettings: {
+        baseUrl: "https://api.example.com",
+        timeoutMs: 5000,
+        retryCount: 3,
+        headers: { "x-api-key": "test-key" },
+      },
+      databaseConfig: {
+        connectionString: "postgres://localhost:5432/mydb",
+        poolSize: 10,
+        migrations: {
+          enableAuto: true,
+          scriptsPath: "/migrations",
+        },
+      },
+      featureFlags: {
+        newUI: true,
+        BETA_FEATURES: false,
+        "experimental-api": true,
+      },
+    });
+  });
+});
+
+function inlineAdapter(source: Record<string, unknown>) {
+  return { name: "inline", read: async () => source };
+}
diff --git a/tests/utils.test.ts b/tests/utils.test.ts
index c6151e9..c6f7f8c 100644
--- a/tests/utils.test.ts
+++ b/tests/utils.test.ts
@@ -3,7 +3,7 @@ import {
   filterByPrefixKey,
   filterByRegex,
   isMergeableObject,
-} from "../src/lib/adapters/utils";
+} from "../src/lib/utils";
 import { describe, it, expect } from "vitest";
 
 describe("filterByRegex", () => {


Reviews:
user: login: dirkluijk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T13:49:23Z
user: login: dirkluijk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T13:50:52Z
user: login: dirkluijk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T13:51:11Z
user: login: dirkluijk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T13:51:19Z
user: login: dirkluijk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T13:51:27Z
user: login: dirkluijk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T13:53:02Z
user: login: dirkluijk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T13:53:50Z
user: login: dirkluijk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T14:02:56Z
user: login: alexmarqs
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-28T14:37:54Z
user: login: dirkluijk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T14:51:04Z
user: login: alexmarqs
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-28T14:51:25Z
user: login: alexmarqs
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-28T15:06:38Z
user: login: alexmarqs
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-28T15:14:53Z
user: login: alexmarqs
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-28T15:32:13Z
user: login: dirkluijk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T15:56:57Z
user: login: dirkluijk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T09:04:58Z
user: login: alexmarqs
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-29T10:13:58Z
user: login: dirkluijk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T17:19:46Z
user: login: dirkluijk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T23:02:40Z
user: login: dirkluijk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T23:10:40Z
user: login: alexmarqs
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-30T10:38:02Z
user: login: alexmarqs
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-30T16:14:15Z
user: login: alexmarqs
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-30T16:15:38Z
user: login: dirkluijk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T07:35:56Z
user: login: dirkluijk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T07:45:21Z
user: login: dirkluijk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T07:47:00Z
user: login: alexmarqs
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-01T12:14:49Z
user: login: alexmarqs
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-01T22:52:11Z
user: login: alexmarqs
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-01T22:52:52Z
user: login: dirkluijk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T07:14:15Z

Review comments:
diff_hunk: @@ -443,6 +443,28 @@ const config = await loadConfig({
 });
 ```
 
+### Lenient matching
+
+If the source of your adapters uses a different casing compared to the schema you are using, you can enable the `lenientMatching` option.
+
+```ts
+import { z } from 'zod';
+import { loadConfig } from 'zod-config';
+import { envAdapter } from 'zod-config/env-adapter';
+
+const schemaConfig = z.object({
+  myHost: z.string(),
+});
+
+const config = await loadConfig({
+  schema: schemaConfig,
+  lenientMatching: true,
+  adapters: envAdapter(),
+});
+```
+
+In this example, keys such as `MYHOST` or `MY_HOST` coming from the `envAdapter` are correctly recognized and transformed into `myHost`.
+
path: README.md
user: login: dirkluijk
body: Lots of the examples in the `README` contain incorrect examples, since the `envAdapter()` probably always return everything in UPPER_CASE.

Should we fix those examples? What about making `lenientMatching` the default?
created_at: 2025-04-28T13:49:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,7 +1,7 @@
 import assert from "node:assert";
 import path from "node:path";
 
-import { deepMerge, filteredData } from "../utils";
+import { deepMerge, filteredData } from "../../utils";
path: src/lib/adapters/directory-adapter/index.ts
user: login: dirkluijk
body: I moved the utilities one level up, and made it a directory with a barrel file. This way it's easier to find all the different utilities and makes it easer to see where non-exported functions are used.
created_at: 2025-04-28T13:50:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,37 @@
+import { isMergeableObject } from '@/lib/utils/is-mergeable-object';
+
+export function deepMerge(
+    target: Partial<Record<string, unknown>>,
+    ...sources: unknown[]
+): Partial<Record<string, unknown>> {
+    if (!sources.length) {
+        return target;
+    }
+
+    const source = sources.shift();
+
+    if (source === undefined) {
+        return target;
+    }
+
+    if (isMergeableObject(target) && isMergeableObject(source)) {
+        Object.keys(source).forEach((key) => {
+            if (source[key] === undefined) return;
+
+            if (!isMergeableObject(source[key])) {
+                target[key] = source[key];
+                return;
+            }
+
+            const subTarget = target[key];
+            if (!isMergeableObject(subTarget)) {
+                target[key] = deepMerge({}, source[key]);
+                return;
+            }
+
+            deepMerge(subTarget, source[key]);
+        });
+    }
+
+    return deepMerge(target, ...sources);
+}
path: src/lib/utils/deep-merge.ts
user: login: dirkluijk
body: unchanged, extracted into its own file
created_at: 2025-04-28T13:51:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,52 @@
+import { isMergeableObject } from '@/lib/utils/is-mergeable-object';
+
+/**
+ * Filter the data based on the regex or prefix key (to be deprecated soon)
+ */
+export function filteredData(
+    data: Record<string, unknown>,
+    options: { regex?: RegExp; prefixKey?: string },
+) {
+    const { regex, prefixKey } = options;
+
+    if (regex) {
+        return filterByRegex(data, regex);
+    }
+
+    if (prefixKey) {
+        return filterByPrefixKey(data, prefixKey);
+    }
+
+    return data;
+}
+
+/**
+ * @deprecated To be deprecated soon in favor of filterByRegex
+ */
+export const filterByPrefixKey = (data: unknown, prefixKey: string) => {
+    if (data == null) return {};
+    if (!isMergeableObject(data))
+        throw new TypeError(`Cannot filter ${data} by prefix key as it is not a record-like object`);
+
+    return Object.keys(data)
+        .filter((key) => key.startsWith(prefixKey))
+        .reduce<Partial<Record<string, unknown>>>((acc, key) => {
+            acc[key] = data[key];
+
+            return acc;
+        }, {});
+};
+
+export const filterByRegex = (data: unknown, regex: RegExp) => {
+    if (data == null) return {};
+    if (!isMergeableObject(data))
+        throw new TypeError(`Cannot filter ${data} by regex as it is not a record-like object`);
+
+    return Object.keys(data)
+        .filter((key) => regex.test(key))
+        .reduce<Partial<Record<string, unknown>>>((acc, key) => {
+            acc[key] = data[key];
+
+            return acc;
+        }, {});
+};
path: src/lib/utils/filtered-data.ts
user: login: dirkluijk
body: unchanged, extracted into its own file
created_at: 2025-04-28T13:51:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,9 @@
+export function isMergeableObject(item: unknown): item is Partial<Record<string, unknown>> {
+    if (!item) return false;
+    if (typeof item !== "object") return false;
+    // ES6 class instances, Maps, Sets, Arrays, etc. are not considered records
+    if (Object.getPrototypeOf(item) === Object.prototype) return true;
+    // Some library/Node.js functions return records with null prototype
+    if (Object.getPrototypeOf(item) === null) return true;
+    return false;
+}
path: src/lib/utils/is-mergeable-object.ts
user: login: dirkluijk
body: unchanged, extracted into its own file
created_at: 2025-04-28T13:51:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,35 @@
+import { type AnyZodObject, ZodObject, type ZodRawShape } from "zod";
+import { isMergeableObject } from '@/lib/utils/is-mergeable-object';
+
+/**
+ * Returns the data, with all the nested keys transformed to match the shape of the schema.
+ */
+export function applyLenientMatching(
+  data: Record<string, unknown>,
+  shape: ZodRawShape,
+): Record<string, unknown> {
+  return Object.fromEntries(
+    Object.entries(data).map(([key, value]) => {
+      const matchedKey =
+        Object.keys(shape).find(
+          (it) =>
+            it.replace(/[^a-z0-9]/gi, "").toLowerCase() ===
+            key.replace(/[^a-z0-9]/gi, "").toLowerCase(),
+        ) ?? key;
+
+        const zodType = shape[matchedKey];
+        if (isMergeableObject(value) && isZodObject(zodType)) {
+        return [
+          matchedKey,
+          applyLenientMatching(value, zodType.shape),
+        ];
+      } else {
+        return [matchedKey, value];
+      }
+    }),
+  );
+}
+
+function isZodObject(input: unknown): input is AnyZodObject {
+  return input instanceof ZodObject;
+}
path: src/lib/utils/lenient-matching.ts
user: login: dirkluijk
body: It basically deep-transforms the merged data to have the object keys match the corresponding shapes of the Zod schema.

By performing a case-insensitive comparison against the alphanumerical-only characters, it should match any naming convention difference and keeps the implementation detail of this feature away from the public API.
created_at: 2025-04-28T13:53:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -11,7 +11,7 @@ export type Adapter<T extends z.AnyZodObject = z.AnyZodObject> = {
   /**
    * Read the config
    */
-  read: () => Promise<z.infer<T>>;
+  read: () => Promise<z.infer<T>> | z.infer<T>;
path: src/types.ts
user: login: dirkluijk
body: One small improvement, could be extracted to a separate PR. This basically allows any adapter to also return synchronous stuff as well.
created_at: 2025-04-28T13:53:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,69 @@
+import { describe, expect, it, vi } from "vitest";
+import { z } from "zod";
+
+import { loadConfig } from "@/index";
+
+describe("loadConfig()", () => {
+    it("should perform lenient matching", async () => {
+        const MyConfig = z.object({
+            foo: z.object({
+                enabled: z.boolean().default(true),
+                nestedProp: z.string(),
+                OTHER_PROP: z.string(),
+            }),
+        });
+
+        const mockedLogger = { warn: vi.fn() };
+        const config = await loadConfig({
+            logger: mockedLogger,
+            schema: MyConfig,
+            lenientMatching: true,
+            adapters: [
+                inlineAdapter({ FOO: { NESTED_PROP: "Foo!", otherProp: "Test" } }),
+            ],
+        });
+
+        expect(config).toEqual({
+            foo: {
+                enabled: true,
+                nestedProp: "Foo!",
+                OTHER_PROP: "Test",
+            },
+        });
+    });
+
+    it("lenient matching should preserve order of adapter", async () => {
+        const MyConfig = z.object({
+            foo: z.object({
+                enabled: z.boolean().default(true),
+                nestedProp: z.string(),
+                OTHER_PROP: z.string(),
+            }),
+        });
+
+        const mockedLogger = { warn: vi.fn() };
+        const config = await loadConfig({
+            logger: mockedLogger,
+            schema: MyConfig,
+            lenientMatching: true,
+            adapters: [
+                inlineAdapter({ FOO: { nestedProp: "Foo!", otherProp: "Test" } }),
+                inlineAdapter({ FOO: { NESTED_PROP: "Override1", otherProp: "Override1" } }),
+                inlineAdapter({ foo: { NESTED_PROP: "Override2", otherProp: "Override2" } }),
+            ],
+        });
+
+        expect(config).toEqual({
+            foo: {
+                enabled: true,
+                nestedProp: "Override2",
+                OTHER_PROP: "Override2",
+            },
+        });
+    });
+});
+
+// candidate for the library?
+function inlineAdapter(source: Record<string, unknown>) {
+    return { name: 'inline', read: () => source };
+}
path: tests/lenient-matching.test.ts
user: login: dirkluijk
body: This function might be nice to add to the library. When chaining multiple adapters as functions, it looks a bit awkward to express an inline source adapter as object literal, e.g.

```ts
adapters: [
   inlineAdapter(source),
   envAdapter(),
   yamlAdapter(),
]
```
over
```ts
adapters: [
   { name: 'inline', read: () => source },
   envAdapter(),
   yamlAdapter(),
]
```
created_at: 2025-04-28T14:02:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -11,7 +11,7 @@ export type Adapter<T extends z.AnyZodObject = z.AnyZodObject> = {
   /**
    * Read the config
    */
-  read: () => Promise<z.infer<T>>;
+  read: () => Promise<z.infer<T>> | z.infer<T>;
path: src/types.ts
user: login: alexmarqs
body: Yep, this is something that I wanted to introduce as well, can we please move it out from this PR, feel free to open a new one later just to introduce sync load config 🙏 
created_at: 2025-04-28T14:37:54Z
author_association: OWNER
diff_hunk: @@ -11,7 +11,7 @@ export type Adapter<T extends z.AnyZodObject = z.AnyZodObject> = {
   /**
    * Read the config
    */
-  read: () => Promise<z.infer<T>>;
+  read: () => Promise<z.infer<T>> | z.infer<T>;
path: src/types.ts
user: login: dirkluijk
body: Done!
created_at: 2025-04-28T14:51:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,7 +1,7 @@
 import assert from "node:assert";
 import path from "node:path";
 
-import { deepMerge, filteredData } from "../utils";
+import { deepMerge, filteredData } from "../../utils";
path: src/lib/adapters/directory-adapter/index.ts
user: login: alexmarqs
body: awesome thank you!
created_at: 2025-04-28T14:51:25Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,35 @@
+import { type AnyZodObject, ZodObject, type ZodRawShape } from "zod";
+import { isMergeableObject } from '@/lib/utils/is-mergeable-object';
+
+/**
+ * Returns the data, with all the nested keys transformed to match the shape of the schema.
+ */
+export function applyLenientMatching(
+  data: Record<string, unknown>,
+  shape: ZodRawShape,
+): Record<string, unknown> {
+  return Object.fromEntries(
+    Object.entries(data).map(([key, value]) => {
+      const matchedKey =
+        Object.keys(shape).find(
+          (it) =>
+            it.replace(/[^a-z0-9]/gi, "").toLowerCase() ===
+            key.replace(/[^a-z0-9]/gi, "").toLowerCase(),
+        ) ?? key;
+
+        const zodType = shape[matchedKey];
+        if (isMergeableObject(value) && isZodObject(zodType)) {
+        return [
+          matchedKey,
+          applyLenientMatching(value, zodType.shape),
+        ];
+      } else {
+        return [matchedKey, value];
+      }
+    }),
+  );
+}
+
+function isZodObject(input: unknown): input is AnyZodObject {
+  return input instanceof ZodObject;
+}
path: src/lib/utils/lenient-matching.ts
user: login: alexmarqs
body: Can we make internally already something like:

```
const KEY_MATCHERS = {
  LENIENT: (key: string, shapeKey: string) =>
    key.replace(/[^a-z0-9]/gi, "").toLowerCase() ===
    shapeKey.replace(/[^a-z0-9]/gi, "").toLowerCase(),
};
```

By default now we can apply this but later we can extend it. Wdyt?
created_at: 2025-04-28T15:06:38Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,35 @@
+import { type AnyZodObject, ZodObject, type ZodRawShape } from "zod";
+import { isMergeableObject } from '@/lib/utils/is-mergeable-object';
+
+/**
+ * Returns the data, with all the nested keys transformed to match the shape of the schema.
+ */
+export function applyLenientMatching(
+  data: Record<string, unknown>,
+  shape: ZodRawShape,
+): Record<string, unknown> {
+  return Object.fromEntries(
+    Object.entries(data).map(([key, value]) => {
+      const matchedKey =
+        Object.keys(shape).find(
+          (it) =>
+            it.replace(/[^a-z0-9]/gi, "").toLowerCase() ===
+            key.replace(/[^a-z0-9]/gi, "").toLowerCase(),
+        ) ?? key;
+
+        const zodType = shape[matchedKey];
+        if (isMergeableObject(value) && isZodObject(zodType)) {
+        return [
+          matchedKey,
+          applyLenientMatching(value, zodType.shape),
+        ];
+      } else {
+        return [matchedKey, value];
+      }
+    }),
+  );
+}
+
+function isZodObject(input: unknown): input is AnyZodObject {
+  return input instanceof ZodObject;
+}
path: src/lib/utils/lenient-matching.ts
user: login: alexmarqs
body: If so, can we make the `applyLenientMatching` into something line `applyKeyMatching` where we can pass 1 or more matchers as argument? 
created_at: 2025-04-28T15:14:53Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,69 @@
+import { describe, expect, it, vi } from "vitest";
+import { z } from "zod";
+
+import { loadConfig } from "@/index";
+
+describe("loadConfig()", () => {
+    it("should perform lenient matching", async () => {
+        const MyConfig = z.object({
+            foo: z.object({
+                enabled: z.boolean().default(true),
+                nestedProp: z.string(),
+                OTHER_PROP: z.string(),
+            }),
+        });
+
+        const mockedLogger = { warn: vi.fn() };
+        const config = await loadConfig({
+            logger: mockedLogger,
+            schema: MyConfig,
+            lenientMatching: true,
+            adapters: [
+                inlineAdapter({ FOO: { NESTED_PROP: "Foo!", otherProp: "Test" } }),
+            ],
+        });
+
+        expect(config).toEqual({
+            foo: {
+                enabled: true,
+                nestedProp: "Foo!",
+                OTHER_PROP: "Test",
+            },
+        });
+    });
+
+    it("lenient matching should preserve order of adapter", async () => {
+        const MyConfig = z.object({
+            foo: z.object({
+                enabled: z.boolean().default(true),
+                nestedProp: z.string(),
+                OTHER_PROP: z.string(),
+            }),
+        });
+
+        const mockedLogger = { warn: vi.fn() };
+        const config = await loadConfig({
+            logger: mockedLogger,
+            schema: MyConfig,
+            lenientMatching: true,
+            adapters: [
+                inlineAdapter({ FOO: { nestedProp: "Foo!", otherProp: "Test" } }),
+                inlineAdapter({ FOO: { NESTED_PROP: "Override1", otherProp: "Override1" } }),
+                inlineAdapter({ foo: { NESTED_PROP: "Override2", otherProp: "Override2" } }),
+            ],
+        });
+
+        expect(config).toEqual({
+            foo: {
+                enabled: true,
+                nestedProp: "Override2",
+                OTHER_PROP: "Override2",
+            },
+        });
+    });
+});
+
+// candidate for the library?
+function inlineAdapter(source: Record<string, unknown>) {
+    return { name: 'inline', read: () => source };
+}
path: tests/lenient-matching.test.ts
user: login: alexmarqs
body: Something like?:
```

export function inlineAdapter(name: string, read: () => Promise<Record<string, unknown>>, options?: BaseAdapterProps): Adapter {
  return {
    name,
    read,
    ...options,
  };
}
```

then in the tests: `inlineAdapter("inline", async () => ({ FOO: { NESTED_PROP: "Foo!", otherProp: "Test" } })),`. 

I still have mixed feelings about the total gain for more complex adapters because probably they will not be created inline anyway. For this test purpose it makes sense as we want to immediately return the source but for other cases not sure yet. Maybe something to consider together with the sync config loader mode ;). tks!
created_at: 2025-04-28T15:32:13Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,35 @@
+import { type AnyZodObject, ZodObject, type ZodRawShape } from "zod";
+import { isMergeableObject } from '@/lib/utils/is-mergeable-object';
+
+/**
+ * Returns the data, with all the nested keys transformed to match the shape of the schema.
+ */
+export function applyLenientMatching(
+  data: Record<string, unknown>,
+  shape: ZodRawShape,
+): Record<string, unknown> {
+  return Object.fromEntries(
+    Object.entries(data).map(([key, value]) => {
+      const matchedKey =
+        Object.keys(shape).find(
+          (it) =>
+            it.replace(/[^a-z0-9]/gi, "").toLowerCase() ===
+            key.replace(/[^a-z0-9]/gi, "").toLowerCase(),
+        ) ?? key;
+
+        const zodType = shape[matchedKey];
+        if (isMergeableObject(value) && isZodObject(zodType)) {
+        return [
+          matchedKey,
+          applyLenientMatching(value, zodType.shape),
+        ];
+      } else {
+        return [matchedKey, value];
+      }
+    }),
+  );
+}
+
+function isZodObject(input: unknown): input is AnyZodObject {
+  return input instanceof ZodObject;
+}
path: src/lib/utils/lenient-matching.ts
user: login: dirkluijk
body: Good idea! Will do!
created_at: 2025-04-28T15:56:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,35 @@
+import { type AnyZodObject, ZodObject, type ZodRawShape } from "zod";
+import { isMergeableObject } from '@/lib/utils/is-mergeable-object';
+
+/**
+ * Returns the data, with all the nested keys transformed to match the shape of the schema.
+ */
+export function applyLenientMatching(
+  data: Record<string, unknown>,
+  shape: ZodRawShape,
+): Record<string, unknown> {
+  return Object.fromEntries(
+    Object.entries(data).map(([key, value]) => {
+      const matchedKey =
+        Object.keys(shape).find(
+          (it) =>
+            it.replace(/[^a-z0-9]/gi, "").toLowerCase() ===
+            key.replace(/[^a-z0-9]/gi, "").toLowerCase(),
+        ) ?? key;
+
+        const zodType = shape[matchedKey];
+        if (isMergeableObject(value) && isZodObject(zodType)) {
+        return [
+          matchedKey,
+          applyLenientMatching(value, zodType.shape),
+        ];
+      } else {
+        return [matchedKey, value];
+      }
+    }),
+  );
+}
+
+function isZodObject(input: unknown): input is AnyZodObject {
+  return input instanceof ZodObject;
+}
path: src/lib/utils/lenient-matching.ts
user: login: dirkluijk
body: Before proceeding, I'd like to point out that exposing the KeyMatcher function in the the public API might be unnecessary, and maybe expose to much implementation detail.

I know it was my own suggestion to begin with when I created the ticket, but I now believe that this lenient matching behavior in 99% of the cases is exactly what you want. 

What about only exposing it as an enum (union) option, so we allow ourselves to tweak this behavior later on without breaking the API?

e.g.
```ts
type Config = {
  // ...

  keyMatching: "lenient" | "strict"
}
```

This could then later be extended (with for example "case-insensitive") if there is a good use case where "lenient" is too lenient and strict too strict? And maybe make "lenient" the default in 1.0?
created_at: 2025-04-29T09:04:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,35 @@
+import { type AnyZodObject, ZodObject, type ZodRawShape } from "zod";
+import { isMergeableObject } from '@/lib/utils/is-mergeable-object';
+
+/**
+ * Returns the data, with all the nested keys transformed to match the shape of the schema.
+ */
+export function applyLenientMatching(
+  data: Record<string, unknown>,
+  shape: ZodRawShape,
+): Record<string, unknown> {
+  return Object.fromEntries(
+    Object.entries(data).map(([key, value]) => {
+      const matchedKey =
+        Object.keys(shape).find(
+          (it) =>
+            it.replace(/[^a-z0-9]/gi, "").toLowerCase() ===
+            key.replace(/[^a-z0-9]/gi, "").toLowerCase(),
+        ) ?? key;
+
+        const zodType = shape[matchedKey];
+        if (isMergeableObject(value) && isZodObject(zodType)) {
+        return [
+          matchedKey,
+          applyLenientMatching(value, zodType.shape),
+        ];
+      } else {
+        return [matchedKey, value];
+      }
+    }),
+  );
+}
+
+function isZodObject(input: unknown): input is AnyZodObject {
+  return input instanceof ZodObject;
+}
path: src/lib/utils/lenient-matching.ts
user: login: alexmarqs
body: I agree 👍  I think ` keyMatching: "lenient" | "strict"` is good. I prefer to keep it "strict" by default now and in the next major version let's switch it to "lenient" as you also mentioned.
created_at: 2025-04-29T10:13:57Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,35 @@
+import { type AnyZodObject, ZodObject, type ZodRawShape } from "zod";
+import { isMergeableObject } from '@/lib/utils/is-mergeable-object';
+
+/**
+ * Returns the data, with all the nested keys transformed to match the shape of the schema.
+ */
+export function applyLenientMatching(
+  data: Record<string, unknown>,
+  shape: ZodRawShape,
+): Record<string, unknown> {
+  return Object.fromEntries(
+    Object.entries(data).map(([key, value]) => {
+      const matchedKey =
+        Object.keys(shape).find(
+          (it) =>
+            it.replace(/[^a-z0-9]/gi, "").toLowerCase() ===
+            key.replace(/[^a-z0-9]/gi, "").toLowerCase(),
+        ) ?? key;
+
+        const zodType = shape[matchedKey];
+        if (isMergeableObject(value) && isZodObject(zodType)) {
+        return [
+          matchedKey,
+          applyLenientMatching(value, zodType.shape),
+        ];
+      } else {
+        return [matchedKey, value];
+      }
+    }),
+  );
+}
+
+function isZodObject(input: unknown): input is AnyZodObject {
+  return input instanceof ZodObject;
+}
path: src/lib/utils/lenient-matching.ts
user: login: dirkluijk
body: Awesome!! I will make the changes today or tomorrow 😁
created_at: 2025-04-29T17:19:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,35 @@
+import { type AnyZodObject, ZodObject, type ZodRawShape } from "zod";
+import { isMergeableObject } from '@/lib/utils/is-mergeable-object';
+
+/**
+ * Returns the data, with all the nested keys transformed to match the shape of the schema.
+ */
+export function applyLenientMatching(
+  data: Record<string, unknown>,
+  shape: ZodRawShape,
+): Record<string, unknown> {
+  return Object.fromEntries(
+    Object.entries(data).map(([key, value]) => {
+      const matchedKey =
+        Object.keys(shape).find(
+          (it) =>
+            it.replace(/[^a-z0-9]/gi, "").toLowerCase() ===
+            key.replace(/[^a-z0-9]/gi, "").toLowerCase(),
+        ) ?? key;
+
+        const zodType = shape[matchedKey];
+        if (isMergeableObject(value) && isZodObject(zodType)) {
+        return [
+          matchedKey,
+          applyLenientMatching(value, zodType.shape),
+        ];
+      } else {
+        return [matchedKey, value];
+      }
+    }),
+  );
+}
+
+function isZodObject(input: unknown): input is AnyZodObject {
+  return input instanceof ZodObject;
+}
path: src/lib/utils/lenient-matching.ts
user: login: dirkluijk
body: I pushed the changes which introduces an internal `KeyMatcher` system. Externally it remains as a simple enum (union).
created_at: 2025-04-29T23:02:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,69 @@
+import { describe, expect, it, vi } from "vitest";
+import { z } from "zod";
+
+import { loadConfig } from "@/index";
+
+describe("loadConfig()", () => {
+    it("should perform lenient matching", async () => {
+        const MyConfig = z.object({
+            foo: z.object({
+                enabled: z.boolean().default(true),
+                nestedProp: z.string(),
+                OTHER_PROP: z.string(),
+            }),
+        });
+
+        const mockedLogger = { warn: vi.fn() };
+        const config = await loadConfig({
+            logger: mockedLogger,
+            schema: MyConfig,
+            lenientMatching: true,
+            adapters: [
+                inlineAdapter({ FOO: { NESTED_PROP: "Foo!", otherProp: "Test" } }),
+            ],
+        });
+
+        expect(config).toEqual({
+            foo: {
+                enabled: true,
+                nestedProp: "Foo!",
+                OTHER_PROP: "Test",
+            },
+        });
+    });
+
+    it("lenient matching should preserve order of adapter", async () => {
+        const MyConfig = z.object({
+            foo: z.object({
+                enabled: z.boolean().default(true),
+                nestedProp: z.string(),
+                OTHER_PROP: z.string(),
+            }),
+        });
+
+        const mockedLogger = { warn: vi.fn() };
+        const config = await loadConfig({
+            logger: mockedLogger,
+            schema: MyConfig,
+            lenientMatching: true,
+            adapters: [
+                inlineAdapter({ FOO: { nestedProp: "Foo!", otherProp: "Test" } }),
+                inlineAdapter({ FOO: { NESTED_PROP: "Override1", otherProp: "Override1" } }),
+                inlineAdapter({ foo: { NESTED_PROP: "Override2", otherProp: "Override2" } }),
+            ],
+        });
+
+        expect(config).toEqual({
+            foo: {
+                enabled: true,
+                nestedProp: "Override2",
+                OTHER_PROP: "Override2",
+            },
+        });
+    });
+});
+
+// candidate for the library?
+function inlineAdapter(source: Record<string, unknown>) {
+    return { name: 'inline', read: () => source };
+}
path: tests/lenient-matching.test.ts
user: login: dirkluijk
body: I do have use a lot of cases where we need to import a raw value from a dynamic import (since we are using esbuild), and I was also writing a quick adapter that just takes a value coming from a custom function call. It would be nice to be able to define as a nice clean inline function.

This could then also serve as a low-level building bricks (high-order function) for a composable API.

I would then just call it `adapter()`:

```
export function adapter(read: () => Promise<Record<string, unknown>>, name: string?, options?: BaseAdapterProps): Adapter {
  return {
    read,
    name ?? "anonymous", // or make completely optional, only used in logger.
    ...options,
  };
}
```

But I'd say, let's discuss in a separate issue/PR, since in here it's also not the proposal right now but just a local util.

created_at: 2025-04-29T23:10:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,69 @@
+import { describe, expect, it, vi } from "vitest";
+import { z } from "zod";
+
+import { loadConfig } from "@/index";
+
+describe("loadConfig()", () => {
+    it("should perform lenient matching", async () => {
+        const MyConfig = z.object({
+            foo: z.object({
+                enabled: z.boolean().default(true),
+                nestedProp: z.string(),
+                OTHER_PROP: z.string(),
+            }),
+        });
+
+        const mockedLogger = { warn: vi.fn() };
+        const config = await loadConfig({
+            logger: mockedLogger,
+            schema: MyConfig,
+            lenientMatching: true,
+            adapters: [
+                inlineAdapter({ FOO: { NESTED_PROP: "Foo!", otherProp: "Test" } }),
+            ],
+        });
+
+        expect(config).toEqual({
+            foo: {
+                enabled: true,
+                nestedProp: "Foo!",
+                OTHER_PROP: "Test",
+            },
+        });
+    });
+
+    it("lenient matching should preserve order of adapter", async () => {
+        const MyConfig = z.object({
+            foo: z.object({
+                enabled: z.boolean().default(true),
+                nestedProp: z.string(),
+                OTHER_PROP: z.string(),
+            }),
+        });
+
+        const mockedLogger = { warn: vi.fn() };
+        const config = await loadConfig({
+            logger: mockedLogger,
+            schema: MyConfig,
+            lenientMatching: true,
+            adapters: [
+                inlineAdapter({ FOO: { nestedProp: "Foo!", otherProp: "Test" } }),
+                inlineAdapter({ FOO: { NESTED_PROP: "Override1", otherProp: "Override1" } }),
+                inlineAdapter({ foo: { NESTED_PROP: "Override2", otherProp: "Override2" } }),
+            ],
+        });
+
+        expect(config).toEqual({
+            foo: {
+                enabled: true,
+                nestedProp: "Override2",
+                OTHER_PROP: "Override2",
+            },
+        });
+    });
+});
+
+// candidate for the library?
+function inlineAdapter(source: Record<string, unknown>) {
+    return { name: 'inline', read: () => source };
+}
path: tests/lenient-matching.test.ts
user: login: alexmarqs
body: Yeah agree, let's then discuss it in another thread of new PR or issue, happy to refine the idea! 
created_at: 2025-04-30T10:38:02Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,69 @@
+import { describe, expect, it, vi } from "vitest";
+import { z } from "zod";
+
+import { loadConfig } from "@/index";
+
+describe("loadConfig()", () => {
+    it("should perform lenient matching", async () => {
+        const MyConfig = z.object({
+            foo: z.object({
+                enabled: z.boolean().default(true),
+                nestedProp: z.string(),
+                OTHER_PROP: z.string(),
+            }),
+        });
+
+        const mockedLogger = { warn: vi.fn() };
+        const config = await loadConfig({
+            logger: mockedLogger,
+            schema: MyConfig,
+            lenientMatching: true,
+            adapters: [
+                inlineAdapter({ FOO: { NESTED_PROP: "Foo!", otherProp: "Test" } }),
+            ],
+        });
+
+        expect(config).toEqual({
+            foo: {
+                enabled: true,
+                nestedProp: "Foo!",
+                OTHER_PROP: "Test",
+            },
+        });
+    });
+
+    it("lenient matching should preserve order of adapter", async () => {
+        const MyConfig = z.object({
+            foo: z.object({
+                enabled: z.boolean().default(true),
+                nestedProp: z.string(),
+                OTHER_PROP: z.string(),
+            }),
+        });
+
+        const mockedLogger = { warn: vi.fn() };
+        const config = await loadConfig({
+            logger: mockedLogger,
+            schema: MyConfig,
+            lenientMatching: true,
+            adapters: [
+                inlineAdapter({ FOO: { nestedProp: "Foo!", otherProp: "Test" } }),
+                inlineAdapter({ FOO: { NESTED_PROP: "Override1", otherProp: "Override1" } }),
path: tests/lenient-matching.test.ts
user: login: alexmarqs
body: With nested properties like this, we may run into issues. Consider the following example in a test:
```
      adapters: [
        inlineAdapter({ FOO: { nestedProp: "Foo!", otherProp: "Test" } }),
        inlineAdapter({ FOO: { NESTED_PROP: "Override1", otherProp: "Override1" } }),
        inlineAdapter({ foo: { NESTED_PROP: "Override2", otherProp: "Override2" } }),
        inlineAdapter({ FOO: { NESTED_PROP: "Override3" } }),
      ],
```
Since key matching happens after merging all the adapters, the final shape ends up like this:
```
{
  FOO: { 
    nestedProp: "Foo!", 
    otherProp: "Override1",
    NESTED_PROP: "Override3"
  },
  foo: {
    NESTED_PROP: "Override2",
    otherProp: "Override2"
  }
}
```
Later in the code, we do something like `const matchedKey = Object.keys(shape).find((...`. --> Because of that, `nestedProp` can resolve from the wrong branch—specifically, being `Override2` instead of `Override3`. This might surprise users who expects "Override3" in the result as in the end "FOO" should match "foo" when merging.

What was your original intention here / for this scenario? Happy to work on a fix if needed. Thanks!
created_at: 2025-04-30T16:14:15Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,35 @@
+import { type AnyZodObject, ZodObject, type ZodRawShape } from "zod";
+import { isMergeableObject } from '@/lib/utils/is-mergeable-object';
+
+/**
+ * Returns the data, with all the nested keys transformed to match the shape of the schema.
+ */
+export function applyLenientMatching(
+  data: Record<string, unknown>,
+  shape: ZodRawShape,
+): Record<string, unknown> {
+  return Object.fromEntries(
+    Object.entries(data).map(([key, value]) => {
+      const matchedKey =
+        Object.keys(shape).find(
+          (it) =>
+            it.replace(/[^a-z0-9]/gi, "").toLowerCase() ===
+            key.replace(/[^a-z0-9]/gi, "").toLowerCase(),
+        ) ?? key;
+
+        const zodType = shape[matchedKey];
+        if (isMergeableObject(value) && isZodObject(zodType)) {
+        return [
+          matchedKey,
+          applyLenientMatching(value, zodType.shape),
+        ];
+      } else {
+        return [matchedKey, value];
+      }
+    }),
+  );
+}
+
+function isZodObject(input: unknown): input is AnyZodObject {
+  return input instanceof ZodObject;
+}
path: src/lib/utils/lenient-matching.ts
user: login: alexmarqs
body: All good on this regards, thanks!
created_at: 2025-04-30T16:15:38Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,69 @@
+import { describe, expect, it, vi } from "vitest";
+import { z } from "zod";
+
+import { loadConfig } from "@/index";
+
+describe("loadConfig()", () => {
+    it("should perform lenient matching", async () => {
+        const MyConfig = z.object({
+            foo: z.object({
+                enabled: z.boolean().default(true),
+                nestedProp: z.string(),
+                OTHER_PROP: z.string(),
+            }),
+        });
+
+        const mockedLogger = { warn: vi.fn() };
+        const config = await loadConfig({
+            logger: mockedLogger,
+            schema: MyConfig,
+            lenientMatching: true,
+            adapters: [
+                inlineAdapter({ FOO: { NESTED_PROP: "Foo!", otherProp: "Test" } }),
+            ],
+        });
+
+        expect(config).toEqual({
+            foo: {
+                enabled: true,
+                nestedProp: "Foo!",
+                OTHER_PROP: "Test",
+            },
+        });
+    });
+
+    it("lenient matching should preserve order of adapter", async () => {
+        const MyConfig = z.object({
+            foo: z.object({
+                enabled: z.boolean().default(true),
+                nestedProp: z.string(),
+                OTHER_PROP: z.string(),
+            }),
+        });
+
+        const mockedLogger = { warn: vi.fn() };
+        const config = await loadConfig({
+            logger: mockedLogger,
+            schema: MyConfig,
+            lenientMatching: true,
+            adapters: [
+                inlineAdapter({ FOO: { nestedProp: "Foo!", otherProp: "Test" } }),
+                inlineAdapter({ FOO: { NESTED_PROP: "Override1", otherProp: "Override1" } }),
path: tests/lenient-matching.test.ts
user: login: dirkluijk
body: Good catch! Of course, it should happen before merging. I was already suspecting issues, but I wrote the test incorrectly. I will fix it.
created_at: 2025-05-01T07:35:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,69 @@
+import { describe, expect, it, vi } from "vitest";
+import { z } from "zod";
+
+import { loadConfig } from "@/index";
+
+describe("loadConfig()", () => {
+    it("should perform lenient matching", async () => {
+        const MyConfig = z.object({
+            foo: z.object({
+                enabled: z.boolean().default(true),
+                nestedProp: z.string(),
+                OTHER_PROP: z.string(),
+            }),
+        });
+
+        const mockedLogger = { warn: vi.fn() };
+        const config = await loadConfig({
+            logger: mockedLogger,
+            schema: MyConfig,
+            lenientMatching: true,
+            adapters: [
+                inlineAdapter({ FOO: { NESTED_PROP: "Foo!", otherProp: "Test" } }),
+            ],
+        });
+
+        expect(config).toEqual({
+            foo: {
+                enabled: true,
+                nestedProp: "Foo!",
+                OTHER_PROP: "Test",
+            },
+        });
+    });
+
+    it("lenient matching should preserve order of adapter", async () => {
+        const MyConfig = z.object({
+            foo: z.object({
+                enabled: z.boolean().default(true),
+                nestedProp: z.string(),
+                OTHER_PROP: z.string(),
+            }),
+        });
+
+        const mockedLogger = { warn: vi.fn() };
+        const config = await loadConfig({
+            logger: mockedLogger,
+            schema: MyConfig,
+            lenientMatching: true,
+            adapters: [
+                inlineAdapter({ FOO: { nestedProp: "Foo!", otherProp: "Test" } }),
+                inlineAdapter({ FOO: { NESTED_PROP: "Override1", otherProp: "Override1" } }),
path: tests/lenient-matching.test.ts
user: login: dirkluijk
body: I extended the test a bit, and fixed the issue:
https://github.com/alexmarqs/zod-config/pull/28/commits/3bd6af02d766e74de227d9c603d5ed1438e0f94d
created_at: 2025-05-01T07:45:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -21,6 +21,8 @@ export const loadConfig = async <T extends AnyZodObject>(
   const data = await getDataFromAdapters(
     Array.isArray(adapters) ? adapters : adapters ? [adapters] : [],
     logger,
+    config.schema,
+    config.keyMatching ?? "strict",
path: src/lib/config.ts
user: login: dirkluijk
body: We could also pass the entire config here?
created_at: 2025-05-01T07:46:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,69 @@
+import { describe, expect, it, vi } from "vitest";
+import { z } from "zod";
+
+import { loadConfig } from "@/index";
+
+describe("loadConfig()", () => {
+    it("should perform lenient matching", async () => {
+        const MyConfig = z.object({
+            foo: z.object({
+                enabled: z.boolean().default(true),
+                nestedProp: z.string(),
+                OTHER_PROP: z.string(),
+            }),
+        });
+
+        const mockedLogger = { warn: vi.fn() };
+        const config = await loadConfig({
+            logger: mockedLogger,
+            schema: MyConfig,
+            lenientMatching: true,
+            adapters: [
+                inlineAdapter({ FOO: { NESTED_PROP: "Foo!", otherProp: "Test" } }),
+            ],
+        });
+
+        expect(config).toEqual({
+            foo: {
+                enabled: true,
+                nestedProp: "Foo!",
+                OTHER_PROP: "Test",
+            },
+        });
+    });
+
+    it("lenient matching should preserve order of adapter", async () => {
+        const MyConfig = z.object({
+            foo: z.object({
+                enabled: z.boolean().default(true),
+                nestedProp: z.string(),
+                OTHER_PROP: z.string(),
+            }),
+        });
+
+        const mockedLogger = { warn: vi.fn() };
+        const config = await loadConfig({
+            logger: mockedLogger,
+            schema: MyConfig,
+            lenientMatching: true,
+            adapters: [
+                inlineAdapter({ FOO: { nestedProp: "Foo!", otherProp: "Test" } }),
+                inlineAdapter({ FOO: { NESTED_PROP: "Override1", otherProp: "Override1" } }),
path: tests/lenient-matching.test.ts
user: login: alexmarqs
body: awesome, now it looks good! later in the day will give my last check, tks 💪 
created_at: 2025-05-01T12:14:49Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,69 @@
+import { describe, expect, it, vi } from "vitest";
+import { z } from "zod";
+
+import { loadConfig } from "@/index";
+
+describe("loadConfig()", () => {
+    it("should perform lenient matching", async () => {
+        const MyConfig = z.object({
+            foo: z.object({
+                enabled: z.boolean().default(true),
+                nestedProp: z.string(),
+                OTHER_PROP: z.string(),
+            }),
+        });
+
+        const mockedLogger = { warn: vi.fn() };
+        const config = await loadConfig({
+            logger: mockedLogger,
+            schema: MyConfig,
+            lenientMatching: true,
+            adapters: [
+                inlineAdapter({ FOO: { NESTED_PROP: "Foo!", otherProp: "Test" } }),
+            ],
+        });
+
+        expect(config).toEqual({
+            foo: {
+                enabled: true,
+                nestedProp: "Foo!",
+                OTHER_PROP: "Test",
+            },
+        });
+    });
+
+    it("lenient matching should preserve order of adapter", async () => {
+        const MyConfig = z.object({
+            foo: z.object({
+                enabled: z.boolean().default(true),
+                nestedProp: z.string(),
+                OTHER_PROP: z.string(),
+            }),
+        });
+
+        const mockedLogger = { warn: vi.fn() };
+        const config = await loadConfig({
+            logger: mockedLogger,
+            schema: MyConfig,
+            lenientMatching: true,
+            adapters: [
+                inlineAdapter({ FOO: { nestedProp: "Foo!", otherProp: "Test" } }),
+                inlineAdapter({ FOO: { NESTED_PROP: "Override1", otherProp: "Override1" } }),
path: tests/lenient-matching.test.ts
user: login: alexmarqs
body: I have added the lenient key matching section to the TOC (table of contents) in the README, did some small lint fixes and added new test; commits [here](https://github.com/alexmarqs/zod-config/pull/28/commits/1000bb5b0c9c6a3585d20bda0ee12af8cda57948) and [here](https://github.com/alexmarqs/zod-config/pull/28/commits/fd299329f0b3febd2aec84f4c2b15b1719029b39)
created_at: 2025-05-01T22:52:11Z
author_association: OWNER
diff_hunk: @@ -21,6 +21,8 @@ export const loadConfig = async <T extends AnyZodObject>(
   const data = await getDataFromAdapters(
     Array.isArray(adapters) ? adapters : adapters ? [adapters] : [],
     logger,
+    config.schema,
+    config.keyMatching ?? "strict",
path: src/lib/config.ts
user: login: alexmarqs
body: All good for now 👍 
created_at: 2025-05-01T22:52:52Z
author_association: OWNER
diff_hunk: @@ -1,4 +1,4 @@
-import { AnyZodObject, z } from "zod";
+import type { AnyZodObject, z } from "zod";
path: src/lib/config.ts
user: login: dirkluijk
body: Good catch!
created_at: 2025-05-02T07:14:14Z
author_association: CONTRIBUTOR

PR comments:
user: login: dirkluijk
created_at: 2025-05-02T07:12:54Z
body: Awesome, nice collaboration. ✅😁
author_association: CONTRIBUTOR
user: login: alexmarqs
created_at: 2025-05-02T08:06:38Z
body: > Awesome, nice collaboration. ✅😁

Agree 💯 ! Thanks for that 💪 ! 
author_association: OWNER