Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support different naming conventions for object keys #22

Open
dirkluijk opened this issue Mar 11, 2025 · 6 comments
Open

Support different naming conventions for object keys #22

dirkluijk opened this issue Mar 11, 2025 · 6 comments

Comments

@dirkluijk
Copy link

dirkluijk commented Mar 11, 2025

There is currently a limitation (or maybe even a bug) that prevents me from using multiple adapters in zod-config, and that is different naming conventions for different sources.

A very obvious one is the envAdapter: environment variables often use "CONSTANT_CASE", whereas JSON/YAML files often use "PascalCase", "camelCase" or "kebab-case".

Considering a schema like:

const mySchema = z.object({
    foo: z.string(),
    bar: z.coerce.number(),
});

This looks very similar to the example in the README, but throws an error for me when the adapter reads the keys in upper case:

const env = {
    FOO: 'Foo!',
    BAR: '123',
};

await loadConfig({
  schema: mySchema,
  adapters: [envAdapter({ customEnv: env })],
})

Looking at the source code, this seems to be intended (as in, not implemented). A simple trick could be to make the matching case insensitive, but this would still not work since some naming conventions introduce dashes or underscores.

I've been thinking about possible solutions, and I have a few ideas:

  1. Don't support it in zod-config. Leave any transformations up to the user, e.g. by using z.preprocess(input => ...) (blocked by Use z.ZodType<object> instead of z.AnyZodObject #21). The downside is that this will lead to a lot of boilerplate in userland code, or forces users to implement their own adapter, which in my opinion defeats the purpose of this library.
  2. Add a generic transformation function to loadConfig(), which allows users to match object keys with their corresponding schema key
  3. Same as 2, but on Adapter level; so you can configure this per adapter
  4. Same as 3, but instead of a custom function, implement specific naming conventions in specific adapters; and make this behavior configurable. For example, have envAdapter recognize CONSTANT_CASE and transform the object key to match the schema key.

Personally I think it makes most sense to go for solution 3 or 4; as it should up to the adapter to "adapt" the object to match the schema.

For solution 3/4, you could have the option to provide a KeyMatcher:

/**
 * Whether the key from a value matches with the key of a schema.
 */
type KeyMatcher = (valueKey: string, schemaKey: string) => boolean;

/**
 * Example of some key matchers
 */
const KEY_MATCHERS = {
    EQUALS: (valueKey, schemaKey) => valueKey === schemaKey,
    CASE_INSENSITIVE: (valueKey, schemaKey) => valueKey.toLowerCase() === schemaKey.toLowerCase(),
    KEBAB_CASE: (valueKey, schemaKey) => kebabCase(schemaKey) === kebabCase(valueKey),
    // ...
} as const satisfies Record<string, KeyMatcher>;

Solution 3 would expose this concept to the user, solution 4 would keep this hidden as implementation detail.

Oh, and both solutions would require the adapter to access the schema, see #23.

@dirkluijk dirkluijk changed the title Allow case transformations for object keys to enable different naming conventions Support different naming conventions for object keys Mar 11, 2025
@alexmarqs
Copy link
Owner

Thanks for your input, @dirkluijk! This wasn't initially considered, but your point is absolutely valid. Let's work on a solution for this. Here's my proposal considering the current limitation of #21:

  • We introduce a new global argument called transformKey in loadConfig(), which will be applied to every top-level key. This allows us to standardise key transformations across adapters.
  • Additionally, we can also introduce a transformData function that gives you access to the entire data structure before it's evaluated. This will allow you to modify top-level keys as well as nested data if needed.
  • For native and built-in adapters, we should ensure that both transformKey and transformData can be used. The global options will take priority, as they will be applied to the final data just before it is parsed by the schema. Regarding custom adapters is up to the user of course.

What do you think? With this solution dont see need to propagate the schema to the adapter read operation, maybe I'm missing something 🤔 . Happy to discuss different approaches!

Thank you!

@dirkluijk
Copy link
Author

dirkluijk commented Mar 11, 2025

What's the difference between transformKey and transformData? Assuming the first would only allow changing the keys, and the latter the whole thing, I don't see why one would need both ;)

I gave it some more thought.

As for different use cases, I think it there are multiple to take into account:

  • first, cases where you always apply the same transformation to the source: this would cover situations like environment variables that needs conversion from "CONSTANT_CASE" to "camelCase". You would indeed only need access to the source, and not to the schema. However, a big limitation with this approach is that this would assume that "camelCase" is always used in the target schema and not anything else, like "PascalCase". But, since it is the user that decides, this is not necessarily an issue. But it limits the flexibility a bit.
  • however, there are also situations where multiple transformations are needed. An obvious one is where the source of the keys should be matched case insensitive. Or take Spring Boot with its relaxed binding for example, it supports to match both some-property and someProperty.

To allow the second case (now or in the future), it would be more practical to work with a callback that matches a source key with a schema key. Hence my suggestion for the KeyMatcher above. Zod config would than internally transform the source object when a key matches.

const mySchema = z.object({
    foo: z.string(),
    bar: z.coerce.number(),
});

// assuming this env:
const env = {
    FOO: 'Foo!',
    BAR: '123',
};

await loadConfig({
  schema: mySchema,
  matchKey: (sourceKey, schemaKey) => sourceKey.toUpperCase() === schemaKey
  adapters: [envAdapter()],
})

However, to me it makes even more sense to not bother the user with these kind of things at all, and make this "relaxed binding" the default behavior of zod-config, where an internal mechanism always tries to match in a certain priority (first exact matches, then case insensitive, then other common naming conventions, or just stripping away any non-alphanumerical characters and do a case insensitive match).

Instead, we’d only provide the user with a simple property to disable this behavior with a strictMatch: boolean or something similar. I think as user you would generally prefer "relaxed binding" over strictness (but this might also depend on the source).

There are also cases where certain transformations are clearly adapter-specific. For example, looking at Spring Boot (which is a great reference to me for its flexibility), it allows you to even map environment variables to nested properties. Another feature would be to automatically apply coercion without having to explicitly set this in your schema, since environment variables are always strings. If this is done by adjusting the Zod schema (since it already has coercion built-in) or separately is another implementation detail.

These kind of things would of course be very specific to an env adapter implementation. But this does not rule out the global transformation behavior and could be a nice addition to it.

@alexmarqs
Copy link
Owner

Thanks for your insights, @dirkluijk! I see how relaxed binding is especially useful when handling configurations from different sources with different naming conventions. The Spring Boot docs you shared are truly inspiring on this topic 😉.

Here are the key points to consider:

  • By default, we can apply some relaxed binding rules (to be clarified) when reading data from adapters, which should be factored into the data merging logic.
  • strictMatch: Defaults to false, but can be set to true to maintain the existing behavior.
  • matchKey: An optional custom callback function for matching logic.

If I understand correctly, you were referring to cases like the following—am I right?

const schema = z.object({
  database: z.object({
    connection: z.object({
      host: z.string(),
      port: z.number()
    })
  })
});

// First source (e.g. default config)
const config1 = {
  database: {
    connection: {
      host: 'localhost',
      port: 5432
    }
  }
};

// Second source (e.g. environment variables)
const config2 = {
  'DATABASE_CONNECTION_HOST': 'production.example.com',
  'database.connection.port': '6543'
};

// Result after merging and transformation:
{
  database: {
    connection: {
      host: 'production.example.com',  // from DATABASE_CONNECTION_HOST
      port: 6543                       // from database.connection.port
    }
  }
}

@dirkluijk
Copy link
Author

dirkluijk commented Mar 12, 2025

Yes, exactly!

I would consider the feature of relaxed binding (which deals with case transformations only) to be a generic feature, but I would treat the case of matching env variables to nested properties as a separate case specifically for the env adapter, since that is something you only need for environment variables (as they only exist as top-level string values) and might require additional investigation on how to deal with ambiguity. Same applies for coercion, by the way.

E.g.

{
  foo: { bar: string },
  fooBar: string
}

@dirkluijk
Copy link
Author

Let me know if you accept PRs, I’m willing to contribute. 😁

@alexmarqs
Copy link
Owner

Contributions / PR's are more than welcome for sure @dirkluijk! Feel free to start one. If not, probably I will start one soon :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants