t3-env icon indicating copy to clipboard operation
t3-env copied to clipboard

Allow for ZodObject to be passed directly to `client`/`server` keys instead of an object of ZodTypes

Open kpervin opened this issue 1 year ago • 12 comments

Currently in @t3-oss/env-nextjs you are meant to define your env as follows:

const env = createEnv({
  server: {
    FOO: z.string().required()
  },
  client: {
    BAR: z.string().required()
  }
});

However, there are times where you might want to define these ZodObjects outside of your env.mjs file and simply pass them:

/* External file*/
export const ServerSchema = z.object({
  FOO: z.string().required()
});
export const ClientSchema = z.object({
  BAR: z.string().required()
});


/* env.mjs */
const env = create schema({
  server: ServerSchema,
  client: ClientSchema
});

This however does not work and the resulting env has type Record<string, {}> because it wasn't expecting a ZodObject. It would be nice if we could pass pre-defined ZodObjects to these keys and retain type safety.

kpervin avatar Jan 23 '24 03:01 kpervin

Closing as you can just use Schema.shape, however it would be good to include that in the docs.

/* External file*/
export const ServerSchema = z.object({
  FOO: z.string().required()
});
export const ClientSchema = z.object({
  BAR: z.string().required()
});


/* env.mjs */
const env = create schema({
  server: ServerSchema.shape,
  client: ClientSchema.shape
});

kpervin avatar Jan 23 '24 15:01 kpervin

I believe there's significant value in considering the capability to pass a complete Zod schema, as opposed to merely an object structure, in our configuration. This feature would offer greater flexibility, particularly in complex scenarios where multiple conditional validations are necessary.

Example

Take, for instance, an app that allows a different set of authentication providers. In such cases, it's crucial to ensure that correlated environment variables are collectively defined. For example, if GOOGLE_CLIENT_ID is provided, it's imperative that GOOGLE_CLIENT_SECRET is also present, and not just marked as optional.

Example schema

const GoogleAuthSchema = z.object({
  GOOGLE_CLIENT_ID: z.string(),
  GOOGLE_CLIENT_SECRET: z.string(),
});

const ResendSchema = z.object({
  RESEND_API_KEY: z.string(),
});

const ServerSchema = z
  .object({
    NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
    NEXTAUTH_SECRET: process.env.NODE_ENV === "production" ? z.string() : z.string().optional(),
  })
  .and(z.union([GoogleAuthSchema, ResendSchema]));

Resulting env type

type ServerSchemaType = {
  NODE_ENV: "development" | "test" | "production";
  NEXTAUTH_SECRET?: string | undefined;
} & (
  | {
      GOOGLE_CLIENT_ID: string;
      GOOGLE_CLIENT_SECRET: string;
    }
  | {
      RESEND_API_KEY: string;
    }
);

shkreios avatar Jan 29 '24 16:01 shkreios

I opened a new issue #176

shkreios avatar Jan 29 '24 16:01 shkreios

@shkreios I could simply reopen this one, if you'd like

kpervin avatar Jan 29 '24 16:01 kpervin

@shkreios I could simply reopen this one, if you'd like

If thats possible, yes feel free to, maybe you can copy the new issue text into an updated request section or so

shkreios avatar Jan 30 '24 11:01 shkreios

Any updates here? I would appreciate this feature, as I want to utilize super refine to validate some variables to be required only if another one is a certain value.

wfl-junior avatar Feb 02 '24 15:02 wfl-junior

feel free to open a PR - iirc the reason why i did an object was cause inferring the shape didn't work properly with z.object() .

Don't try to make it backwards compat, lib is still on 0.x so we can make a breaking change that requries everyone to use z.object()

juliusmarminge avatar Feb 07 '24 23:02 juliusmarminge

If there's a technical limitation to z.object, I wonder if there's an innovative workaround.

I'm thinking, what if there were another parameter: discriminatedUnions (name up for debate, picked something illustrative).

E.g. given this createEnv call:

export const env = createEnv({
  runtimeEnv: process.env,
  emptyStringAsUndefined: true,
  server: {
	NODE_ENV: z.enum(['development', 'test', 'production']),
	url: z.string(),
	otherStuff: z.string().optional(),
  },
  discriminatedUnions: {
    NODE_ENV: {
	  development: {
        url: z.literal('http://localhost'),
      },
      test: {
        url: z.literal('https://test.example.com'), 
      },
      production: {
        url: z.literal('https://example.com'),
        otherStuff: z.string(),
      },
    }
  }
});

When parsed, the resulting environment object would have this type:

```typescript
type Env = 
| {
    NODE_ENV: 'production',
    url: z.literal('https://example.com'),
    otherStuff: string,
  }
| (
  & { otherStuff?: string | undefined }
  & (
    | {
        NODE_ENV: 'development',
        url: 'http://localhost',
      }
    | {
       NODE_ENV: 'test',
       url: 'https://test.example.com',
      }
    )
)

(The convoluted way of representing that type is intentional, intended to represent the type I would expect to see given how this would likely need to be implemented, e.g. with stuff like Exclude<Obj1, keyof Obj2> & Obj2[keyof Obj1 & keyof Obj2]

In essence, this would effectively mean parsing the environment once with the top-level validation, then creating a discriminated union parser (non-strict) that a runs secondary validation and unions the resulting types.

The need for secondary validation is somewhat mitigated by using the more performant ZodDiscriminatedUnion.

Worth noting:

We would need type magic to validate that the top-level keys of the "discriminatedUnions" object are valid, and we would need additional type magic to ensure that the keys of each "discriminatedUnion" object are assignable to the type asserted by the primary validation schema, and to ensure that the sub-schemas provided represent valid subsets of the primary validation schema.

helmturner avatar Aug 25 '24 17:08 helmturner

If there's a technical limitation to z.object, I wonder if there's an innovative workaround.

No it should be doable. I just recall it being a pain validating the shape's keys when the input was a ZodObject. Should be doable but I don't see it as enough a benefit to justify the time it would take to dig into it now.

If anyone has time and need this feature feel free to submit a PR

juliusmarminge avatar Aug 25 '24 18:08 juliusmarminge

I think I may be able to make it work, though it will take some time (correct type inference is hard).

Btw, this issue only talks about server and client, should shared also be changed?

feder240516 avatar Sep 08 '24 03:09 feder240516

Yea everything.

juliusmarminge avatar Sep 08 '24 08:09 juliusmarminge

@feder240516 Any way I can help? I'm pretty nifty with type inference, and could certainly use this for work!

helmturner avatar Oct 01 '24 19:10 helmturner