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

Allow defining a different key into the final destination object after parsing #486

Closed
larsbs opened this issue Jun 7, 2021 · 22 comments
Closed
Labels
wontfix This will not be worked on

Comments

@larsbs
Copy link

larsbs commented Jun 7, 2021

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):

  • Validate the data and then transform the data, so our validators will refer to something that doesn't really exist in the app
  • Transform the data and then validate having to transform loosely typed data.

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:

const UserSchema = z.object({
  firstName: z.fromKey('first_name').string()
});

The code above will validate that first_name is present and is a string, while at the same time, producing a firstName key with the correctly validated data.

@scotttrinh
Copy link
Collaborator

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>;

@larsbs
Copy link
Author

larsbs commented Jun 8, 2021

@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.

@colinhacks
Copy link
Owner

I'm not a fan of the z.fromKey proposal. This is a transformation that happens at the object-level, so defining the key remapping on a per-key basis is weird to me. I'd consider a .remap method on ZodObject:

const UserSchema = z.object({
  first_name: z.string(),
}).remap({
  first_name: 'firstName'
});

Under the hood this would just be syntactic sugar on top of .transform. Thoughts @larsbs?

@stale
Copy link

stale bot commented Mar 2, 2022

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.

@stale stale bot added the wontfix This will not be worked on label Mar 2, 2022
@stale stale bot closed this as completed Mar 9, 2022
@kevinmamaqi
Copy link

kevinmamaqi commented May 1, 2022

@colinhacks Is there a remap method?

@hokyunrhee
Copy link

@colinhacks Is there a remap method?

you can do this.

const UserSchema = z.object({
  first_name: z.string(),
}).transform((user) => ({
  firstName: user.first_name
}));

@gligoran
Copy link

@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.

@donferi
Copy link

donferi commented Nov 17, 2022

We have the same use-case where doing this with transform is unmanageable for large objects.

@stalebot open 😂

@carlgieringer
Copy link
Contributor

carlgieringer commented Jan 4, 2023

@gligoran I'm using a pattern like this (momentObject and EntityRowId are just zod defs defined elsewhere.)

import { CamelCasedProperties } from "type-fest";
import { camelCase, mapKeys } from "lodash";

const camelCaseKey = (_val: any, key: string) => camelCase(key);

export const UserRow = z
  .object({
    user_id: EntityRowId,
    email: z.string().email(),
    short_name: z.string(),
    long_name: z.string(),
    phone_number: z.string(),
    creator_user_id: EntityRowId,
    last_login: momentObject,
    created: momentObject,
    deleted: momentObject,
    is_active: z.boolean(),
    username: z.string(),
    accepted_terms: momentObject,
    affirmed_majority_consent: momentObject,
    affirmed_13_years_or_older: momentObject,
    affirmed_not_gdpr: momentObject,
  })
  .transform(({ user_id, ...o }) => ({
    id: user_id,
    ...(mapKeys(o, camelCaseKey) as CamelCasedProperties<typeof o>),
  }));
export type UserRow = z.input<typeof UserRow>;
export type UserData = z.output<typeof UserRow>;

type-fest has a CamelCasedPropertiesDeep and deepdash has a mapKeysDeep. Maybe you could follow a similar pattern but with the deep helpers?

@antonioalmeida
Copy link

antonioalmeida commented Mar 20, 2023

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 zodToCamelCase can be improved because the result of z.input<typeof SchemaCamelCase> is any. But for now it works

@kayandra
Copy link

kayandra commented Apr 9, 2023

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 zodToCamelCase can be improved because the result of z.input<typeof SchemaCamelCase> is any. But for now it works

@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>)

@rintaun
Copy link

rintaun commented May 29, 2023

Using CamelCasedPropertiesDeep breaks things like z.date(), unfortunately. Removing the 'deep' requirement solves that problem, and removes the need for type-fest, to boot.

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 z.object needs to be transform(camelize)ed manually, but imo this is the correct way to go about it, anyway.

@niebag
Copy link

niebag commented Jun 26, 2023

Neat solution @rintaun!

@filipedelmonte
Copy link

filipedelmonte commented Jul 13, 2023

You could also just fix type-fest's CamelCasePropertiesDeep type so it skips serialising Date, Regex and some others js primitives

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
      >;
    };

@thewebartisan7
Copy link

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 })

@apatrida
Copy link

apatrida commented Mar 6, 2024

Doesn't the transform "work around" require a delete of the old value as well? And does the TS type track all of that?

@Tockra
Copy link

Tockra commented Jun 6, 2024

Today I ran into a similar issue.
Our backend does not support zod. So we don't share the zod schema between backend and frontend.
But the programming language we are using in the backend uses snake_case (rust) but our frontend code uses camelCase everywhere.

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 ...

@athielba
Copy link

I managed to make it work like below
For Example:

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>;

@danielgrittner
Copy link

@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:

#[derive(Debug, Default, Serialize, Deserialize)]
// this does the magic
#[serde(rename_all = "camelCase")]
pub struct Paginated<T> {
    pub list: Vec<T>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub next_offset: Option<i64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub has_more: Option<bool>,
}

// JSON OUTPUT:
/*
{
  "list": [],
  "nextOffset": 1,
  "hasMore": false
}

*/

@alexgorbatchev
Copy link

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>);

@BhupenPal
Copy link

BhupenPal commented Sep 2, 2024

@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.
There was a use case where we sure that there are a few array fields which will have exactly one element and therefore we were using tuple in our zod schema to handle those fields.
And to my surprise the CamelCasedPropertiesDeep was not handling the tuples. So, I had to modify the solution a little bit to make it work with tuples.

type CamelCasedPropertiesDeep<Value> = Value extends () =>
  | any
  | Date
  | RegExp
  ? Value
  : Value extends [infer First, ...infer Rest]
    ? [CamelCasedPropertiesDeep<First>, ...CamelCasedPropertiesDeep<Rest>]
    : Value extends Array<infer U>
      ? Array<CamelCasedPropertiesDeep<U>>
      : Value extends Set<infer U>
        ? Set<CamelCasedPropertiesDeep<U>>
        : {
            [K in keyof Value as CamelCase<
              K,
              { preserveConsecutiveUppercase: true }
            >]: CamelCasedPropertiesDeep<Value[K]>;
          };

Please feel free to report any bug or errors in this.

@mozharovsky
Copy link

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]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
wontfix This will not be worked on
Projects
None yet
Development

No branches or pull requests