-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
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
Allow defining a different key into the final destination object after parsing #486
Comments
I wonder if this could be implemented as a "preprocess" step via #468 ? Definitely something that we run into that we have some just shared machinery around automatically transforming data before running through our schemas, so I'd be interested to see a general purpose solution for this. Rust's Serde uses something they call Attributes to support this, but I don't think we have anything quite like that at our disposal in TS/JS land. Another approach is to use a transformer at the end of your schema and define your schema based on the input type. const UserSchema = z.object({
first_name: z.string(),
}).transform((input) => ({
firstName: input.first_name,
});
type User = z.output<typeof UserSchema>; |
@scotttrinh Yeah, that's a useful workaround! but what I loved the first time about zod is that you could just define the structure of your data once instead of twice (one for validation, one for TS) like with other libraries. |
I'm not a fan of the const UserSchema = z.object({
first_name: z.string(),
}).remap({
first_name: 'firstName'
}); Under the hood this would just be syntactic sugar on top of |
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. |
@colinhacks Is there a remap method? |
you can do this. const UserSchema = z.object({
first_name: z.string(),
}).transform((user) => ({
firstName: user.first_name
})); |
This works on a small scale, but when you work with bigger types, this gets way too duplicative. Here's an example I've just started working on, but the actual type is massive: import { z } from 'zod';
const DictionarySchema = z.object({
basic: z.string(),
});
export const StorySchema = z
.object({
_id: z.string(),
type: z.literal('story'),
version: z.string(),
headlines: DictionarySchema,
subheadlines: DictionarySchema,
promo_items: z.object({
basic: z.object({
url: z.string().url(),
}),
}),
related_content: z.object({
basic: z.array(
z.object({
subtype: z.string(),
embed: z.object({
config: z.string(),
}),
}),
),
}),
websites: z.string(),
})
.transform((story) => {
const { _id: id, promo_items: promoItems, related_content: relatedContent, ...rest } = story;
return {
...rest,
id,
promoItems,
relatedContent,
};
});
export type Story = z.infer<typeof StorySchema>; As you can see, I remap all the keys I want to rename, but maintaining this would be a nightmare. I tried something like this (simplified, non-deep version): .transform((story) => {
const newStory = Object.fromEntries(Object.entries(story).map(([key, value]) => [key.toUpperCase(), value]));
return newStory;
}); But the inferred type that comes of this is a garbled mess: type Story = {
[k: string]: string | {
basic: string;
} | {
basic: {
url: string;
};
} | {
basic: {
subtype: string;
embed: {
config: string;
};
}[];
};
} It would be amazing to have an option to remap these values in the object definition. |
We have the same use-case where doing this with transform is unmanageable for large objects. @stalebot open 😂 |
@gligoran I'm using a pattern like this (
|
Inspired by @carlgieringer 's suggestion I managed to solve my particular case: getting both a snake_case and camelCase (after transformation) version of the zod schemas and respective types. import camelcaseKeys from 'camelcase-keys'
import { CamelCasedPropertiesDeep } from 'type-fest' // need CamelCasedPropertiesDeep because of https://github.com/sindresorhus/camelcase-keys/issues/77#issuecomment-1339844470
export const zodToCamelCase = <T extends z.ZodTypeAny>(zod: T): ZodEffects<z.ZodTypeAny, CamelCasedPropertiesDeep<T['_output']>> => zod.transform((val) => camelcaseKeys(val) as CamelCasedPropertiesDeep<T>) Usage const SchemaSnakeCase = z.object({
key_in_object: z.string(),
another_key: z.object({
this_also_works: z.string(),
a_number: z.number().optional()
})
})
const SchemaCamelCase = zodToCamelCase(SchemaSnakeCase)
// Usage
SchemaSnakeCase.parse({ key_in_object: 'asd' }) // { key_in_object: 'asd' }
SchemaCamelCase.parse({ key_in_object: 'asd' }) // { keyInObject: 'asd' }
// Getting types
type TypeSnakeCase = z.infer<typeof SchemaSnakeCase>
// type TypeSnakeCase = {
// key_in_object: string;
// another_key: {
// a_number?: number | undefined;
// this_also_works: string;
// };
// }
type TypeCamelCase = z.output<typeof SchemaCamelCase>
// type TypeCamelCase = {
// keyInObject: string;
// anotherKey: {
// aNumber?: number | undefined;
// thisAlsoWorks: string;
// };
// } I think the types of |
@antonioalmeida I love this! I think we can infer the types instead of passing any. import camelcaseKeys from 'camelcase-keys'
import { CamelCasedPropertiesDeep } from 'type-fest' // need CamelCasedPropertiesDeep because of https://github.com/sindresorhus/camelcase-keys/issues/77#issuecomment-1339844470
export const zodToCamelCase = <T extends z.ZodTypeAny>(zod: T): ZodEffects<z.infer<T>, CamelCasedPropertiesDeep<T['_output']>> => zod.transform((val) => camelcaseKeys(val) as CamelCasedPropertiesDeep<T>) |
Using Here's what I'm using: const camelize = <T extends readonly unknown[] | Record<string, unknown>>(
val: T,
) => camelcaseKeys(val)
const Schema = z
.object({
key_in_object: z.string(),
another_key: z
.object({
this_also_works: z.string(),
a_number: z.number().optional(),
})
.transform(camelize),
})
.transform(camelize)
type TypeSnakeCase = z.input<typeof Schema>
// type TypeSnakeCase = {
// key_in_object: string;
// another_key: {
// this_also_works: string;
// a_number?: number | undefined;
// };
// }
type TypeCamelCase = z.output<typeof Schema>
// type TypeCamelCase = {
// keyInObject: string;
// anotherKey: {
// thisAlsoWorks: string;
// aNumber?: number | undefined;
// };
// } A downside is that each |
Neat solution @rintaun! |
You could also just fix type-fest's import { CamelCase } from 'type-fest';
export type CamelCaseOptions = {
preserveConsecutiveUppercase?: boolean;
};
export type CamelCasedPropertiesDeep<
Value,
Options extends CamelCaseOptions = { preserveConsecutiveUppercase: true },
> = Value extends Function | Date | RegExp // skip js primitives
? Value
: Value extends Array<infer U>
? Array<CamelCasedPropertiesDeep<U, Options>>
: Value extends Set<infer U>
? Set<CamelCasedPropertiesDeep<U, Options>>
: {
[K in keyof Value as CamelCase<K, Options>]: CamelCasedPropertiesDeep<
Value[K],
Options
>;
}; |
About proposed solution to remap key using transform, how then I can parse transformed object? Example: const UserDTOSchema = z.object({
first_name: z.string()
})
const UserDomainSchema = UserDTOSchema.transform((input) => ({
firstName: input.first_name
}))
type UserDTOSchema = z.output<typeof UserDTOSchema>
type UserDomainSchema = z.output<typeof UserDomainSchema>
const parsed1: UserDTOSchema = UserDTOSchema.parse({
first_name: 'Name'
})
// Can't parse here with remapped keys `{ firstName: 'Name' } `
const parsed2: UserDomainSchema = UserDomainSchema.parse({
first_name: 'Name'
})
console.log({ parsed1, parsed2 }) |
Doesn't the transform "work around" require a delete of the old value as well? And does the TS type track all of that? |
Today I ran into a similar issue. Now I'm asking myself if I need to have my schema twice (one camel case and one snake case schema). This would be very annoying ... |
I managed to make it work like below import type { z } from 'zod';
import camelcaseKeys from 'camelcase-keys';
export const linkedAppsSchema = z.object({
team_member_id: z.string().min(1),
linked_api_apps: z.array(
z.object({
app_id: z.string().min(1),
app_name: z.string().min(1),
linked: z.string().optional(),
publisher: z.string().optional(),
publisher_url: z.string().optional(),
})
),
});
const linkedAppsSchemaCamelKeys = linkedAppsSchema.transform((data) => {
return camelcaseKeys(data, { deep: true });
})
export type LinkedApps = z.infer<typeof linkedAppsSchemaCamelKeys>; |
@Tockra If you use Rust with serde, you can simply rename the keys during serialization, so they can get automatically camelcased on the backend. Example:
|
here's a working solution that i put together based on the comments above: import { CamelCase } from 'type-fest';
import { z } from 'zod';
import camelcaseKeys from 'camelcase-keys';
type CamelCaseOptions = {
preserveConsecutiveUppercase?: boolean;
};
type CamelCasedPropertiesDeep<
Value,
Options extends CamelCaseOptions = { preserveConsecutiveUppercase: true },
// biome-ignore lint/complexity/noBannedTypes: need Function here
> = Value extends Function | Date | RegExp // skip js primitives
? Value
: Value extends Array<infer U>
? Array<CamelCasedPropertiesDeep<U, Options>>
: Value extends Set<infer U>
? Set<CamelCasedPropertiesDeep<U, Options>>
: {
[K in keyof Value as CamelCase<K, Options>]: CamelCasedPropertiesDeep<Value[K], Options>;
};
export const camelizeSchema = <T extends z.ZodTypeAny>(
zod: T,
): z.ZodEffects<z.infer<T>, CamelCasedPropertiesDeep<T['_output']>> =>
zod.transform(val => camelcaseKeys(val, { deep: true }) as CamelCasedPropertiesDeep<T>); |
@filipedelmonte @alexgorbatchev Huge shoutout to you guys for providing a perfectly working solution. This solution is just extended to provide support for tuples. I am working on a project, which is using Nestjs + TS and it follows a camelCase naming convention for everything. But we had to integrate a 3rd party API which uses snake_case.
Please feel free to report any bug or errors in this. |
I'll add my 2 cents here with another modification that effectively solves the tuples issue, but meanwhile preserves the original author's options signature: import camelcaseKeys from "camelcase-keys";
import { type CamelCase } from "type-fest";
import { type z } from "zod";
type CamelCaseOptions = {
preserveConsecutiveUppercase?: boolean;
};
type CamelCasedPropertiesDeep<
Value,
Options extends CamelCaseOptions = { preserveConsecutiveUppercase: true },
// eslint-disable-next-line @typescript-eslint/ban-types
> = Value extends Function | Date | RegExp
? Value
: Value extends readonly unknown[]
? Value extends readonly [infer First, ...infer Rest]
? [CamelCasedPropertiesDeep<First, Options>, ...CamelCasedPropertiesDeep<Rest, Options>]
: Value extends readonly []
? []
: CamelCasedPropertiesDeep<Value[number], Options>[]
: Value extends Set<infer U>
? Set<CamelCasedPropertiesDeep<U, Options>>
: Value extends object
? {
[K in keyof Value as CamelCase<K & string, Options>]: CamelCasedPropertiesDeep<
Value[K],
Options
>;
}
: Value;
export const camelizeSchema = <T extends z.ZodTypeAny>(
zod: T,
): z.ZodEffects<z.infer<T>, CamelCasedPropertiesDeep<T["_output"]>> =>
zod.transform((val) => camelcaseKeys(val, { deep: true }) as CamelCasedPropertiesDeep<T>); Works perfectly fine with these tests: // Example 1: Simple tuple with primitive types
type SimpleTuple = [number, string, boolean];
type CamelCasedSimpleTuple = CamelCasedPropertiesDeep<SimpleTuple>;
// Result: [number, string, boolean]
// Example 2: Tuple with objects
type ObjectTuple = [{ user_id: number }, { user_name: string }];
type CamelCasedObjectTuple = CamelCasedPropertiesDeep<ObjectTuple>;
// Result: [{ userId: number }, { userName: string }]
// Example 3: Nested tuple
type NestedTuple = [number, [string, { nested_field: boolean }]];
type CamelCasedNestedTuple = CamelCasedPropertiesDeep<NestedTuple>;
// Result: [number, [string, { nestedField: boolean }]]
// Example 4: Tuple with array
type TupleWithArray = [{ user_ids: number[] }, string];
type CamelCasedTupleWithArray = CamelCasedPropertiesDeep<TupleWithArray>;
// Result: [{ userIds: number[] }, string]
// Example 5: Complex nested tuple
type ComplexTuple = [
{ user_info: { first_name: string; last_name: string } },
[number, { nested_array: { item_id: number }[] }],
Set<{ set_item: string }>,
];
type CamelCasedComplexTuple = CamelCasedPropertiesDeep<ComplexTuple>;
// Result: [
// { userInfo: { firstName: string; lastName: string } },
// [number, { nestedArray: { itemId: number }[] }],
// Set<{ setItem: string }>
// ]
// Example 6: Empty tuple
type EmptyTuple = [];
type CamelCasedEmptyTuple = CamelCasedPropertiesDeep<EmptyTuple>;
// Result: []
// Example 7: Tuple with function
type TupleWithFunction = [{ callback_fn: () => void }, number];
type CamelCasedTupleWithFunction = CamelCasedPropertiesDeep<TupleWithFunction>;
// Result: [{ callbackFn: () => void }, number] |
Right now zod is becoming more and more an integral part of all my projects, however, there's still a pretty common use case that doesn't have a direct mapping in zod, basically, allowing to validate a different schema than the output one.
Most of the times, in TypeScript codebases, interfacing with an API involves firing a
fetch
request and validating the received data to make sure it corresponds to the shape we're defining in our types. Zod is really great for this due to it's practically 1:1 equivalence with TS typings. However, sometimes, the types we receive from the request don't correspond directly to the types we've defined in our codebase (legacy code, different naming schemas, etc.) and that forces us to having to create yet another layer after validation to do processing.So I was wondering, if it'd be possible and zod architecture would allow to define a way to "collect" keys from the input data and "assign" them to another key in the resulting data.
For example, let's imagine we're building a front-end app using TypeScript (camel case naming) and we request data to our back-end in Rails (snake case naming). Without the functionality described above, we have two solutions (without counting the one that is leaving the naming as is for obvious reasons):
However, adding this functionality to zod, we could do it in just one single step and correctly coupling these two non-independent descriptions thus also providing clarity of intent.
An idea:
The code above will validate that
first_name
is present and is a string, while at the same time, producing afirstName
key with the correctly validated data.The text was updated successfully, but these errors were encountered: