Discriminated types from zod schemas are not inferred properly in custom plugins #1880

Closed
opened 2026-03-13 09:10:23 -05:00 by GiteaMirror · 4 comments
Owner

Originally created by @IamAyaanSk on GitHub (Sep 8, 2025).

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Create a custom better-auth plugin with an endpoint that has zod schema that uses discriminated unions.
  2. Create the client plugin by inferring the server plugin.
  3. Now create a auth client using this client plugin.
  4. Call the endpoint and try to pass params based on the discriminated schema.
  5. Only common properties can be type safely passed here.

Current vs. Expected behavior

I expected the discriminated types to work but only the common properties in the union were available when using the auth client.
For eg:

sendVerificationOTP: createAuthEndpoint(
        '/my-plugin-auth/send-verification-otp',
        {
          method: 'POST',
          body: z.union([
            z.object({
              purpose: z.literal('login'),
              mobileNumber: mobileNumberZod,
              password: z.string().trim(),
            }),
            z.object({
              purpose: z.literal('register'),
              mobileNumber: mobileNumberZod,
              email: emailZod,
            }),
            z.object({
              purpose: z.literal('phone-verification'),
              mobileNumber: mobileNumberZod,
            }),
            z.object({
              purpose: z.literal('email-verification'),
              email: emailZod,
            }),
            z.object({
              purpose: z.literal('password-reset'),
              mobileNumber: mobileNumberZod,
            })
          ]),
....
}

Now if we infer the types and create a client plugin and auth client

const myPluginAuth = () => {
  return {
    id: 'my-plugin',
    $InferServerPlugin: {} as ReturnType<typeof myAuthPlugin>
  } satisfies BetterAuthClientPlugin
}
const authClient = createAuthClient({
  baseURL: 'http://localhost:port', // The base URL of your auth server,
  plugins: [myAuthClient()]
})

authClient.myPluginAuth.sendVerificationOtp({
  purpose: 'email-verification',  // Only this field is inferred since it is common
  email: 'test@example.com'  // This field is not inferred
})

What version of Better Auth are you using?

1.3.8

System info

{
  "system": {
    "platform": "darwin",
    "arch": "arm64",
    "version": "Darwin Kernel Version 24.5.0: Tue Apr 22 19:53:27 PDT 2025; root:xnu-11417.121.6~2/RELEASE_ARM64_T6041",
    "release": "24.5.0",
    "cpuCount": 16,
    "cpuModel": "Apple M4 Max",
    "totalMemory": "48.00 GB",
    "freeMemory": "1.81 GB"
  },
  "node": {
    "version": "v22.14.0",
    "env": "development"
  },
  "packageManager": {
    "name": "pnpm",
    "version": "9.7.1"
  },
  "frameworks": null,
  "databases": null,
  "betterAuth": {
    "version": "0.1.0",
    "config": null
  }
}

Which area(s) are affected? (Select all that apply)

Client

Auth config (if applicable)


Additional context

No response

Originally created by @IamAyaanSk on GitHub (Sep 8, 2025). ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Create a custom better-auth plugin with an endpoint that has zod schema that uses discriminated unions. 2. Create the client plugin by inferring the server plugin. 3. Now create a auth client using this client plugin. 4. Call the endpoint and try to pass params based on the discriminated schema. 5. Only common properties can be type safely passed here. ### Current vs. Expected behavior I expected the discriminated types to work but only the common properties in the union were available when using the auth client. For eg: ```ts sendVerificationOTP: createAuthEndpoint( '/my-plugin-auth/send-verification-otp', { method: 'POST', body: z.union([ z.object({ purpose: z.literal('login'), mobileNumber: mobileNumberZod, password: z.string().trim(), }), z.object({ purpose: z.literal('register'), mobileNumber: mobileNumberZod, email: emailZod, }), z.object({ purpose: z.literal('phone-verification'), mobileNumber: mobileNumberZod, }), z.object({ purpose: z.literal('email-verification'), email: emailZod, }), z.object({ purpose: z.literal('password-reset'), mobileNumber: mobileNumberZod, }) ]), .... } ``` Now if we infer the types and create a client plugin and auth client ```ts const myPluginAuth = () => { return { id: 'my-plugin', $InferServerPlugin: {} as ReturnType<typeof myAuthPlugin> } satisfies BetterAuthClientPlugin } ``` ```ts const authClient = createAuthClient({ baseURL: 'http://localhost:port', // The base URL of your auth server, plugins: [myAuthClient()] }) authClient.myPluginAuth.sendVerificationOtp({ purpose: 'email-verification', // Only this field is inferred since it is common email: 'test@example.com' // This field is not inferred }) ``` ### What version of Better Auth are you using? 1.3.8 ### System info ```bash { "system": { "platform": "darwin", "arch": "arm64", "version": "Darwin Kernel Version 24.5.0: Tue Apr 22 19:53:27 PDT 2025; root:xnu-11417.121.6~2/RELEASE_ARM64_T6041", "release": "24.5.0", "cpuCount": 16, "cpuModel": "Apple M4 Max", "totalMemory": "48.00 GB", "freeMemory": "1.81 GB" }, "node": { "version": "v22.14.0", "env": "development" }, "packageManager": { "name": "pnpm", "version": "9.7.1" }, "frameworks": null, "databases": null, "betterAuth": { "version": "0.1.0", "config": null } } ``` ### Which area(s) are affected? (Select all that apply) Client ### Auth config (if applicable) ```typescript ``` ### Additional context _No response_
GiteaMirror added the plugin label 2026-03-13 09:10:24 -05:00
Author
Owner

@IamAyaanSk commented on GitHub (Sep 8, 2025):

body: z.union([
            z.object({
              purpose: z.literal('login'),
              mobileNumber: mobileNumberZod,
              password: z.string().trim(),
             // Not used
             email: emailZod,
            }),
            z.object({
              purpose: z.literal('register'),
              mobileNumber: mobileNumberZod,
              email: emailZod,
             // Not used
              password: z.string().trim(),
            }),
            z.object({
              purpose: z.literal('phone-verification'),
              mobileNumber: mobileNumberZod,
             // Not used
              password: z.string().trim(),
              password: email,
            }),
            z.object({
              purpose: z.literal('email-verification'),
              email: emailZod,
              ...........
            }),
            z.object({
              purpose: z.literal('password-reset'),
              mobileNumber: mobileNumberZod,
              ..............
            })
          ]),

Currently I am making everything common so this does the thing but I loose strong typesafety in the auth client.

@IamAyaanSk commented on GitHub (Sep 8, 2025): ``` body: z.union([ z.object({ purpose: z.literal('login'), mobileNumber: mobileNumberZod, password: z.string().trim(), // Not used email: emailZod, }), z.object({ purpose: z.literal('register'), mobileNumber: mobileNumberZod, email: emailZod, // Not used password: z.string().trim(), }), z.object({ purpose: z.literal('phone-verification'), mobileNumber: mobileNumberZod, // Not used password: z.string().trim(), password: email, }), z.object({ purpose: z.literal('email-verification'), email: emailZod, ........... }), z.object({ purpose: z.literal('password-reset'), mobileNumber: mobileNumberZod, .............. }) ]), ``` Currently I am making everything common so this does the thing but I loose strong typesafety in the auth client.
Author
Owner

@dosubot[bot] commented on GitHub (Sep 8, 2025):

Hi @IamAyaanSk! I'm Dosu and I’m helping the better-auth team.

This is a known limitation in Better Auth's type inference for client plugins: when you define endpoint schemas using Zod unions (including discriminated unions), the client typings only expose the intersection of all union members—so only the properties common to every variant are type-safe. This happens because the codebase uses a utility called UnionToIntersection to merge endpoint and plugin types, which flattens unions and loses their distinct structure (source).

Currently, there isn't a documented workaround or planned fix for this. If you need strong type safety for each variant, you could consider splitting the endpoint into separate endpoints for each payload shape, or manually annotate types on the client side. Otherwise, making all properties common (as you're doing) is the only way to avoid runtime errors, but it does sacrifice type safety.

If discriminated union support is critical for your workflow, you may want to track future updates or contribute to a solution that improves union type inference in the client plugin system.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Chat with Dosu Join Discord Share on X

@dosubot[bot] commented on GitHub (Sep 8, 2025): <!-- Greeting --> Hi @IamAyaanSk! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> This is a known limitation in Better Auth's type inference for client plugins: when you define endpoint schemas using Zod unions (including discriminated unions), the client typings only expose the intersection of all union members—so only the properties common to every variant are type-safe. This happens because the codebase uses a utility called `UnionToIntersection` to merge endpoint and plugin types, which flattens unions and loses their distinct structure ([source](https://github.com/better-auth/better-auth/blob/a7eaa049e448464ad8501f46136048091a072f43/packages/better-auth/src/client/types.ts)). Currently, there isn't a documented workaround or planned fix for this. If you need strong type safety for each variant, you could consider splitting the endpoint into separate endpoints for each payload shape, or manually annotate types on the client side. Otherwise, making all properties common (as you're doing) is the only way to avoid runtime errors, but it does sacrifice type safety. If discriminated union support is critical for your workflow, you may want to track future updates or contribute to a solution that improves union type inference in the client plugin system. <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/ebc5c9fb-8bb9-4cd6-9bbd-f33d7e8c4241?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/ebc5c9fb-8bb9-4cd6-9bbd-f33d7e8c4241?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/ebc5c9fb-8bb9-4cd6-9bbd-f33d7e8c4241?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/ebc5c9fb-8bb9-4cd6-9bbd-f33d7e8c4241?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/ebc5c9fb-8bb9-4cd6-9bbd-f33d7e8c4241?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/ebc5c9fb-8bb9-4cd6-9bbd-f33d7e8c4241?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/ebc5c9fb-8bb9-4cd6-9bbd-f33d7e8c4241?feedback_type=other)</sup>&nbsp;&nbsp;[![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/4513)
Author
Owner

@IamAyaanSk commented on GitHub (Sep 8, 2025):

@dosu, how can I manually annotate types for the auth client?

@IamAyaanSk commented on GitHub (Sep 8, 2025): @dosu, how can I manually annotate types for the auth client?
Author
Owner

@dosubot[bot] commented on GitHub (Sep 8, 2025):

You can manually annotate types for your auth client endpoint by defining the discriminated union type yourself and using it when calling the endpoint. There are two main approaches:

  1. Annotate the parameter at the call site:
type SendVerificationOtpInput =
  | { purpose: 'login'; mobileNumber: string; password: string }
  | { purpose: 'register'; mobileNumber: string; email: string }
  | { purpose: 'phone-verification'; mobileNumber: string }
  | { purpose: 'email-verification'; email: string }
  | { purpose: 'password-reset'; mobileNumber: string };

// Usage:
authClient.myPluginAuth.sendVerificationOtp({
  purpose: 'email-verification',
  email: 'test@example.com'
} as SendVerificationOtpInput);
  1. Wrap the client method with a custom-typed function:
function sendVerificationOtpTyped(
  params: SendVerificationOtpInput
) {
  return authClient.myPluginAuth.sendVerificationOtp(params);
}

// Usage:
sendVerificationOtpTyped({
  purpose: 'register',
  mobileNumber: '1234567890',
  email: 'test@example.com'
});

This way, you get full type safety for all variants. Just make sure your manual type stays in sync with your server schema, since Better Auth won't enforce this automatically. If you have a shared types package between server and client, you can define the union type once and import it on both sides for better maintainability.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Chat with Dosu Join Discord Share on X

@dosubot[bot] commented on GitHub (Sep 8, 2025): <!-- Answer --> You can manually annotate types for your auth client endpoint by defining the discriminated union type yourself and using it when calling the endpoint. There are two main approaches: 1. Annotate the parameter at the call site: ```ts type SendVerificationOtpInput = | { purpose: 'login'; mobileNumber: string; password: string } | { purpose: 'register'; mobileNumber: string; email: string } | { purpose: 'phone-verification'; mobileNumber: string } | { purpose: 'email-verification'; email: string } | { purpose: 'password-reset'; mobileNumber: string }; // Usage: authClient.myPluginAuth.sendVerificationOtp({ purpose: 'email-verification', email: 'test@example.com' } as SendVerificationOtpInput); ``` 2. Wrap the client method with a custom-typed function: ```ts function sendVerificationOtpTyped( params: SendVerificationOtpInput ) { return authClient.myPluginAuth.sendVerificationOtp(params); } // Usage: sendVerificationOtpTyped({ purpose: 'register', mobileNumber: '1234567890', email: 'test@example.com' }); ``` This way, you get full type safety for all variants. Just make sure your manual type stays in sync with your server schema, since Better Auth won't enforce this automatically. If you have a shared types package between server and client, you can define the union type once and import it on both sides for better maintainability. <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/6dd5dc27-8933-4fe6-9d34-84d34e75abae?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/6dd5dc27-8933-4fe6-9d34-84d34e75abae?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/6dd5dc27-8933-4fe6-9d34-84d34e75abae?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/6dd5dc27-8933-4fe6-9d34-84d34e75abae?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/6dd5dc27-8933-4fe6-9d34-84d34e75abae?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/6dd5dc27-8933-4fe6-9d34-84d34e75abae?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/6dd5dc27-8933-4fe6-9d34-84d34e75abae?feedback_type=other)</sup>&nbsp;&nbsp;[![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/4513)
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#1880