fix(types): prevent any from collapsing base type and client inference (#8981)

This commit is contained in:
Taesu
2026-04-06 21:18:58 +09:00
committed by GitHub
parent dd537cbdeb
commit 560230f751
6 changed files with 128 additions and 53 deletions

View File

@@ -0,0 +1,5 @@
---
"better-auth": patch
---
Prevent `any` from collapsing `auth.$Infer` and `auth.$ERROR_CODES`. Preserve client query typing when body is `any`.

View File

@@ -6,6 +6,7 @@ import type { BetterFetchResponse } from "@better-fetch/fetch";
import type { Endpoint, InputContext, StandardSchemaV1 } from "better-call";
import type {
HasRequiredKeys,
IsAny,
Prettify,
UnionToIntersection,
} from "../types/helper";
@@ -108,30 +109,35 @@ export type InferUserUpdateCtx<
UnionToIntersection<InferAdditionalFromClient<ClientOpts, "user", "input">>
>;
type InferCtxQuery<
C extends InputContext<any, any>,
FetchOptions extends ClientFetchOption,
> =
C["query"] extends Record<string, any>
? {
query: C["query"];
fetchOptions?: FetchOptions | undefined;
}
: C["query"] extends Record<string, any> | undefined
? {
query?: C["query"] | undefined;
fetchOptions?: FetchOptions | undefined;
}
: {
fetchOptions?: FetchOptions | undefined;
};
export type InferCtx<
C extends InputContext<any, any>,
FetchOptions extends ClientFetchOption,
> = 0 extends 1 & C["body"]
? {
fetchOptions?: FetchOptions | undefined;
}
: C["body"] extends Record<string, any>
? C["body"] & {
fetchOptions?: FetchOptions | undefined;
}
: C["query"] extends Record<string, any>
? {
query: C["query"];
> =
IsAny<C["body"]> extends true
? InferCtxQuery<C, FetchOptions>
: C["body"] extends Record<string, any>
? C["body"] & {
fetchOptions?: FetchOptions | undefined;
}
: C["query"] extends Record<string, any> | undefined
? {
query?: C["query"] | undefined;
fetchOptions?: FetchOptions | undefined;
}
: {
fetchOptions?: FetchOptions | undefined;
};
: InferCtxQuery<C, FetchOptions>;
export type MergeRoutes<T> = UnionToIntersection<T>;

View File

@@ -1,3 +1,5 @@
export type IsAny<T> = 0 extends 1 & T ? true : false;
export type Prettify<T> = Omit<T, never>;
export type PrettifyDeep<T> = {
@@ -29,12 +31,41 @@ export type RequiredKeysOf<BaseType extends object> = Exclude<
undefined
>;
export type HasRequiredKeys<BaseType> = 0 extends 1 & BaseType
? false
: [BaseType] extends [object]
? RequiredKeysOf<BaseType & object> extends never
? false
: true
: false;
export type HasRequiredKeys<BaseType> =
IsAny<BaseType> extends true
? false
: [BaseType] extends [object]
? RequiredKeysOf<BaseType & object> extends never
? false
: true
: false;
export type StripEmptyObjects<T extends object> = { [K in keyof T]: T[K] };
/**
* Extracts a Record-typed field from a plugin, guarding against `any`.
*/
export type ExtractPluginField<T, Field extends string> =
IsAny<T> extends true
? {}
: T extends { [K in Field]?: Record<string, any> }
? T[Field] extends Record<string, any>
? T[Field]
: {}
: {};
/**
* Walks a plugin tuple with tail-recursive accumulator (TS 4.5+),
* extracting and intersecting the given field from each element.
*/
export type InferPluginFieldFromTuple<
T extends readonly unknown[],
Field extends string,
Acc = {},
> = T extends readonly [infer Head, ...infer Tail]
? InferPluginFieldFromTuple<
Tail,
Field,
Acc & ExtractPluginField<Head, Field>
>
: Acc;

View File

@@ -1,9 +1,13 @@
import type { BetterAuthOptions, BetterAuthPlugin } from "@better-auth/core";
import type { BetterAuthOptions } from "@better-auth/core";
import type {
InferDBFieldsFromOptionsInput,
InferDBFieldsFromPluginsInput,
} from "@better-auth/core/db";
import type { UnionToIntersection } from "./helper";
import type {
ExtractPluginField,
InferPluginFieldFromTuple,
UnionToIntersection,
} from "./helper";
export type AdditionalUserFieldsInput<Options extends BetterAuthOptions> =
InferDBFieldsFromPluginsInput<"user", Options["plugins"]> &
@@ -14,15 +18,11 @@ export type AdditionalSessionFieldsInput<Options extends BetterAuthOptions> =
InferDBFieldsFromOptionsInput<Options["session"]>;
export type InferPluginTypes<O extends BetterAuthOptions> =
O["plugins"] extends Array<infer P>
? UnionToIntersection<
P extends BetterAuthPlugin
? P["$Infer"] extends Record<string, any>
? P["$Infer"]
: {}
: {}
>
: {};
O["plugins"] extends readonly [unknown, ...unknown[]]
? InferPluginFieldFromTuple<O["plugins"], "$Infer">
: O["plugins"] extends Array<infer P>
? UnionToIntersection<ExtractPluginField<P, "$Infer">>
: {};
export type {
Account,

View File

@@ -5,7 +5,11 @@ import type {
} from "@better-auth/core";
import type { BetterAuthPluginDBSchema } from "@better-auth/core/db";
import type { UnionToIntersection } from "./helper";
import type {
ExtractPluginField,
InferPluginFieldFromTuple,
UnionToIntersection,
} from "./helper";
export type InferOptionSchema<S extends BetterAuthPluginDBSchema> =
S extends Record<string, { fields: infer Fields }>
@@ -22,15 +26,11 @@ export type InferOptionSchema<S extends BetterAuthPluginDBSchema> =
: never;
export type InferPluginErrorCodes<O extends BetterAuthOptions> =
O["plugins"] extends Array<infer P>
? UnionToIntersection<
P extends BetterAuthPlugin
? P["$ERROR_CODES"] extends Record<string, any>
? P["$ERROR_CODES"]
: {}
: {}
>
: {};
O["plugins"] extends readonly [unknown, ...unknown[]]
? InferPluginFieldFromTuple<O["plugins"], "$ERROR_CODES">
: O["plugins"] extends Array<infer P>
? UnionToIntersection<ExtractPluginField<P, "$ERROR_CODES">>
: {};
export type InferPluginIDs<O extends BetterAuthOptions> =
O["plugins"] extends Array<infer P>

View File

@@ -1,5 +1,4 @@
import type { BetterAuthPlugin } from "@better-auth/core";
import type { InputContext } from "better-call";
import { describe, expect, expectTypeOf, it } from "vitest";
import { createAuthEndpoint } from "../api";
import type { InferCtx } from "../client/path-to-object";
@@ -241,10 +240,44 @@ describe("HasRequiredKeys", () => {
});
});
describe("InferCtx", () => {
it("should preserve fetchOptions when body is any", () => {
type Result = InferCtx<InputContext<any, any> & { body: any }, {}>;
type Keys = keyof Result;
expectTypeOf<Keys>().toEqualTypeOf<"fetchOptions">();
describe("any-poisoning guards", () => {
/**
* InferCtx: when body is `any`, query typing should be preserved
* via InferCtxQuery delegation instead of collapsing to `any`.
*/
it("InferCtx should preserve query when body is any", () => {
type Result = InferCtx<
{ body: any; query: { page: number }; method: "GET" },
{}
>;
expectTypeOf<Result["query"]>().toEqualTypeOf<{ page: number }>();
});
/**
* InferPluginTypes: an untyped plugin (`{} as any`) in the plugins array
* should not collapse auth.$Infer to `any`.
*/
it("auth.$Infer should not collapse with untyped plugin", async () => {
const untypedPlugin = {} as any;
const { auth } = await getTestInstance({
plugins: [organization(), untypedPlugin],
});
type Infer = typeof auth.$Infer;
expectTypeOf<Infer>().not.toBeAny();
expectTypeOf<Infer>().toHaveProperty("Session");
});
/**
* InferPluginErrorCodes: same guard as InferPluginTypes,
* auth.$ERROR_CODES should not collapse to `any`.
*/
it("auth.$ERROR_CODES should not collapse with untyped plugin", async () => {
const untypedPlugin = {} as any;
const { auth } = await getTestInstance({
plugins: [organization(), untypedPlugin],
});
type Codes = (typeof auth)["$ERROR_CODES"];
expectTypeOf<Codes>().not.toBeAny();
expectTypeOf<Codes>().toHaveProperty("SESSION_EXPIRED");
});
});