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

Expose an option to customise schema combination #313

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/violet-dryers-cross.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@t3-oss/env-nextjs": minor
"@t3-oss/env-core": minor
"@t3-oss/env-nuxt": minor
---

feat!: added ability to customise schema combination

Combination of schemas can now be customised using the `createFinalSchema` option. This allows further refinement or transformation of the environment variables.

This comes with a type-only breaking change:

- `CreateEnv` now has the signature `CreateEnv<TFinalSchema, TExtends>`, instead of the previous `CreateEnv<TServer, TClient, TShared, TExtends>`.
- Previous behaviour can be achieved by using `DefaultCombinedSchema<TServer, TClient, TShared>` as the type for `TFinalSchema`.
Comment on lines +13 to +14
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this a user-breaking change?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CreateEnv is exported, so technically yes - in practicality I doubt anyone was relying on it

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's exported cause the other packages needs it. I should add some @internal jsdoc annotations to things

Binary file removed bun.lockb
Binary file not shown.
33 changes: 33 additions & 0 deletions docs/src/app/docs/customization/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,36 @@ export const env = createEnv({
extends: [authEnv],
});
```

## Further refinement or transformation

You can use the `createFinalSchema` option to further refine or transform the environment variables.

```ts title="src/env.ts"
import { createEnv } from "@t3-oss/env-core";
import { z } from "zod";

export const env = createEnv({
server: {
SKIP_AUTH: z.boolean().optional(),
EMAIL: z.string().email().optional(),
PASSWORD: z.string().min(1).optional(),
},
// ...
createFinalSchema: (shape, isServer) =>
z.object(shape).transform((env, ctx) => {
if (env.SKIP_AUTH || !isServer) return { SKIP_AUTH: true } as const;
if (!env.EMAIL || !env.PASSWORD) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "EMAIL and PASSWORD are required if SKIP_AUTH is false",
});
return z.NEVER;
}
return {
EMAIL: env.EMAIL,
PASSWORD: env.PASSWORD,
};
}),
});
```
96 changes: 70 additions & 26 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { StandardSchemaDictionary, StandardSchemaV1 } from "./standard";
import { parseWithDictionary } from "./standard";
import { ensureSynchronous, parseWithDictionary } from "./standard";

export type { StandardSchemaV1, StandardSchemaDictionary };

Expand All @@ -8,6 +8,13 @@ export type Simplify<T> = {
[P in keyof T]: T[P];
} & {};

type PossiblyUndefinedKeys<T> = {
[K in keyof T]: undefined extends T[K] ? K : never;
}[keyof T];

type UndefinedOptional<T> = Partial<Pick<T, PossiblyUndefinedKeys<T>>> &
Omit<T, PossiblyUndefinedKeys<T>>;

// biome-ignore lint/suspicious/noExplicitAny: <explanation>
type Impossible<T extends Record<string, any>> = Partial<
Record<keyof T, never>
Expand All @@ -27,7 +34,7 @@ type Reduce<
: never;

export interface BaseOptions<
TShared extends Record<string, StandardSchemaV1>,
TShared extends StandardSchemaDictionary,
TExtends extends Array<Record<string, unknown>>,
> {
/**
Expand Down Expand Up @@ -82,7 +89,7 @@ export interface BaseOptions<
}

export interface LooseOptions<
TShared extends Record<string, StandardSchemaV1>,
TShared extends StandardSchemaDictionary,
TExtends extends Array<Record<string, unknown>>,
> extends BaseOptions<TShared, TExtends> {
runtimeEnvStrict?: never;
Expand All @@ -97,9 +104,9 @@ export interface LooseOptions<

export interface StrictOptions<
TPrefix extends string | undefined,
TServer extends Record<string, StandardSchemaV1>,
TClient extends Record<string, StandardSchemaV1>,
TShared extends Record<string, StandardSchemaV1>,
TServer extends StandardSchemaDictionary,
TClient extends StandardSchemaDictionary,
TShared extends StandardSchemaDictionary,
TExtends extends Array<Record<string, unknown>>,
> extends BaseOptions<TShared, TExtends> {
/**
Expand Down Expand Up @@ -131,7 +138,7 @@ export interface StrictOptions<

export interface ClientOptions<
TPrefix extends string | undefined,
TClient extends Record<string, StandardSchemaV1>,
TClient extends StandardSchemaDictionary,
> {
/**
* The prefix that client-side variables must have. This is enforced both at
Expand All @@ -154,7 +161,7 @@ export interface ClientOptions<

export interface ServerOptions<
TPrefix extends string | undefined,
TServer extends Record<string, StandardSchemaV1>,
TServer extends StandardSchemaDictionary,
> {
/**
* Specify your server-side environment variables schema here. This way you can ensure the app isn't
Expand All @@ -173,43 +180,69 @@ export interface ServerOptions<
}>;
}

export interface CreateSchemaOptions<
TServer extends StandardSchemaDictionary,
TClient extends StandardSchemaDictionary,
TShared extends StandardSchemaDictionary,
TFinalSchema extends StandardSchemaV1<{}, {}>,
> {
/**
* A custom function to combine the schemas.
* Can be used to add further refinement or transformation.
*/
createFinalSchema?: (
shape: TServer & TClient & TShared,
isServer: boolean,
) => TFinalSchema;
}

export type ServerClientOptions<
TPrefix extends string | undefined,
TServer extends Record<string, StandardSchemaV1>,
TClient extends Record<string, StandardSchemaV1>,
TServer extends StandardSchemaDictionary,
TClient extends StandardSchemaDictionary,
> =
| (ClientOptions<TPrefix, TClient> & ServerOptions<TPrefix, TServer>)
| (ServerOptions<TPrefix, TServer> & Impossible<ClientOptions<never, never>>)
| (ClientOptions<TPrefix, TClient> & Impossible<ServerOptions<never, never>>);

export type EnvOptions<
TPrefix extends string | undefined,
TServer extends Record<string, StandardSchemaV1>,
TClient extends Record<string, StandardSchemaV1>,
TShared extends Record<string, StandardSchemaV1>,
TServer extends StandardSchemaDictionary,
TClient extends StandardSchemaDictionary,
TShared extends StandardSchemaDictionary,
TExtends extends Array<Record<string, unknown>>,
> =
TFinalSchema extends StandardSchemaV1<{}, {}>,
> = (
| (LooseOptions<TShared, TExtends> &
ServerClientOptions<TPrefix, TServer, TClient>)
| (StrictOptions<TPrefix, TServer, TClient, TShared, TExtends> &
ServerClientOptions<TPrefix, TServer, TClient>);
ServerClientOptions<TPrefix, TServer, TClient>)
) &
CreateSchemaOptions<TServer, TClient, TShared, TFinalSchema>;

type TPrefixFormat = string | undefined;
type TServerFormat = Record<string, StandardSchemaV1>;
type TClientFormat = Record<string, StandardSchemaV1>;
type TSharedFormat = Record<string, StandardSchemaV1>;
type TServerFormat = StandardSchemaDictionary;
type TClientFormat = StandardSchemaDictionary;
type TSharedFormat = StandardSchemaDictionary;
type TExtendsFormat = Array<Record<string, unknown>>;

export type CreateEnv<
export type DefaultCombinedSchema<
TServer extends TServerFormat,
TClient extends TClientFormat,
TShared extends TSharedFormat,
> = StandardSchemaV1<
{},
UndefinedOptional<
StandardSchemaDictionary.InferOutput<TServer & TClient & TShared>
>
>;

export type CreateEnv<
TFinalSchema extends StandardSchemaV1<{}, {}>,
TExtends extends TExtendsFormat,
> = Readonly<
Simplify<
StandardSchemaDictionary.InferOutput<TServer> &
StandardSchemaDictionary.InferOutput<TClient> &
StandardSchemaDictionary.InferOutput<TShared> &
StandardSchemaV1.InferOutput<TFinalSchema> &
UnReadonlyObject<Reduce<TExtends>>
>
>;
Expand All @@ -220,9 +253,14 @@ export function createEnv<
TClient extends TClientFormat = NonNullable<unknown>,
TShared extends TSharedFormat = NonNullable<unknown>,
const TExtends extends TExtendsFormat = [],
TFinalSchema extends StandardSchemaV1<{}, {}> = DefaultCombinedSchema<
TServer,
TClient,
TShared
>,
>(
opts: EnvOptions<TPrefix, TServer, TClient, TShared, TExtends>,
): CreateEnv<TServer, TClient, TShared, TExtends> {
opts: EnvOptions<TPrefix, TServer, TClient, TShared, TExtends, TFinalSchema>,
): CreateEnv<TFinalSchema, TExtends> {
const runtimeEnv = opts.runtimeEnvStrict ?? opts.runtimeEnv ?? process.env;

const emptyStringAsUndefined = opts.emptyStringAsUndefined ?? false;
Expand All @@ -244,7 +282,7 @@ export function createEnv<
const isServer =
opts.isServer ?? (typeof window === "undefined" || "Deno" in window);

const finalSchema = isServer
const finalSchemaShape = isServer
? {
..._server,
..._shared,
Expand All @@ -255,7 +293,13 @@ export function createEnv<
..._shared,
};

const parsed = parseWithDictionary(finalSchema, runtimeEnv);
const parsed =
opts
.createFinalSchema?.(finalSchemaShape as never, isServer)
["~standard"].validate(runtimeEnv) ??
parseWithDictionary(finalSchemaShape, runtimeEnv);

ensureSynchronous(parsed, "Validation must be synchronous");

const onValidationError =
opts.onValidationError ??
Expand Down
16 changes: 8 additions & 8 deletions packages/core/src/presets-valibot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const vercel = () =>
VERCEL_GIT_COMMIT_AUTHOR_NAME: optional(string()),
VERCEL_GIT_PREVIOUS_SHA: optional(string()),
VERCEL_GIT_PULL_REQUEST_ID: optional(string()),
} satisfies StandardSchemaDictionary.Matching<VercelEnv>,
} satisfies StandardSchemaDictionary<VercelEnv>,
runtimeEnv: process.env,
});

Expand All @@ -66,7 +66,7 @@ export const neonVercel = () =>
POSTGRES_DATABASE: optional(string()),
POSTGRES_URL_NO_SSL: optional(pipe(string(), url())),
POSTGRES_PRISMA_URL: optional(pipe(string(), url())),
} satisfies StandardSchemaDictionary.Matching<NeonVercelEnv>,
} satisfies StandardSchemaDictionary<NeonVercelEnv>,
runtimeEnv: process.env,
});

Expand All @@ -77,7 +77,7 @@ export const uploadthingV6 = () =>
createEnv({
server: {
UPLOADTHING_TOKEN: string(),
} satisfies StandardSchemaDictionary.Matching<UploadThingV6Env>,
} satisfies StandardSchemaDictionary<UploadThingV6Env>,
runtimeEnv: process.env,
});

Expand All @@ -88,7 +88,7 @@ export const uploadthing = () =>
createEnv({
server: {
UPLOADTHING_TOKEN: string(),
} satisfies StandardSchemaDictionary.Matching<UploadThingEnv>,
} satisfies StandardSchemaDictionary<UploadThingEnv>,
runtimeEnv: process.env,
});

Expand All @@ -113,7 +113,7 @@ export const render = () =>
picklist(["web", "pserv", "cron", "worker", "static"]),
),
RENDER: optional(string()),
} satisfies StandardSchemaDictionary.Matching<RenderEnv>,
} satisfies StandardSchemaDictionary<RenderEnv>,
runtimeEnv: process.env,
});

Expand Down Expand Up @@ -147,7 +147,7 @@ export const railway = () =>
RAILWAY_GIT_REPO_NAME: optional(string()),
RAILWAY_GIT_REPO_OWNER: optional(string()),
RAILWAY_GIT_COMMIT_MESSAGE: optional(string()),
} satisfies StandardSchemaDictionary.Matching<RailwayEnv>,
} satisfies StandardSchemaDictionary<RailwayEnv>,
runtimeEnv: process.env,
});

Expand All @@ -169,7 +169,7 @@ export const fly = () =>
FLY_PROCESS_GROUP: optional(string()),
FLY_VM_MEMORY_MB: optional(string()),
PRIMARY_REGION: optional(string()),
} satisfies StandardSchemaDictionary.Matching<FlyEnv>,
} satisfies StandardSchemaDictionary<FlyEnv>,
runtimeEnv: process.env,
});

Expand All @@ -193,6 +193,6 @@ export const netlify = () =>
DEPLOY_ID: optional(string()),
SITE_NAME: optional(string()),
SITE_ID: optional(string()),
} satisfies StandardSchemaDictionary.Matching<NetlifyEnv>,
} satisfies StandardSchemaDictionary<NetlifyEnv>,
runtimeEnv: process.env,
});
16 changes: 8 additions & 8 deletions packages/core/src/presets-zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const vercel = () =>
VERCEL_GIT_COMMIT_AUTHOR_NAME: z.string().optional(),
VERCEL_GIT_PREVIOUS_SHA: z.string().optional(),
VERCEL_GIT_PULL_REQUEST_ID: z.string().optional(),
} satisfies StandardSchemaDictionary.Matching<VercelEnv>,
} satisfies StandardSchemaDictionary<VercelEnv>,
runtimeEnv: process.env,
});

Expand All @@ -66,7 +66,7 @@ export const neonVercel = () =>
POSTGRES_DATABASE: z.string().optional(),
POSTGRES_URL_NO_SSL: z.string().url().optional(),
POSTGRES_PRISMA_URL: z.string().url().optional(),
} satisfies StandardSchemaDictionary.Matching<NeonVercelEnv>,
} satisfies StandardSchemaDictionary<NeonVercelEnv>,
runtimeEnv: process.env,
});

Expand All @@ -77,7 +77,7 @@ export const uploadthingV6 = () =>
createEnv({
server: {
UPLOADTHING_TOKEN: z.string(),
} satisfies StandardSchemaDictionary.Matching<UploadThingV6Env>,
} satisfies StandardSchemaDictionary<UploadThingV6Env>,
runtimeEnv: process.env,
});

Expand All @@ -88,7 +88,7 @@ export const uploadthing = () =>
createEnv({
server: {
UPLOADTHING_TOKEN: z.string(),
} satisfies StandardSchemaDictionary.Matching<UploadThingEnv>,
} satisfies StandardSchemaDictionary<UploadThingEnv>,
runtimeEnv: process.env,
});

Expand All @@ -113,7 +113,7 @@ export const render = () =>
.enum(["web", "pserv", "cron", "worker", "static"])
.optional(),
RENDER: z.string().optional(),
} satisfies StandardSchemaDictionary.Matching<RenderEnv>,
} satisfies StandardSchemaDictionary<RenderEnv>,
runtimeEnv: process.env,
});

Expand Down Expand Up @@ -147,7 +147,7 @@ export const railway = () =>
RAILWAY_GIT_REPO_NAME: z.string().optional(),
RAILWAY_GIT_REPO_OWNER: z.string().optional(),
RAILWAY_GIT_COMMIT_MESSAGE: z.string().optional(),
} satisfies StandardSchemaDictionary.Matching<RailwayEnv>,
} satisfies StandardSchemaDictionary<RailwayEnv>,
runtimeEnv: process.env,
});

Expand All @@ -169,7 +169,7 @@ export const fly = () =>
FLY_PROCESS_GROUP: z.string().optional(),
FLY_VM_MEMORY_MB: z.string().optional(),
PRIMARY_REGION: z.string().optional(),
} satisfies StandardSchemaDictionary.Matching<FlyEnv>,
} satisfies StandardSchemaDictionary<FlyEnv>,
runtimeEnv: process.env,
});

Expand All @@ -193,6 +193,6 @@ export const netlify = () =>
DEPLOY_ID: z.string().optional(),
SITE_NAME: z.string().optional(),
SITE_ID: z.string().optional(),
} satisfies StandardSchemaDictionary.Matching<NetlifyEnv>,
} satisfies StandardSchemaDictionary<NetlifyEnv>,
runtimeEnv: process.env,
});
Loading