You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

TypeScript递归API客户端构建中的类型推断失败问题

TypeScript递归API客户端构建中的类型推断失败问题

看起来你在构建类型安全API客户端时遇到了递归类型推断的问题——你的ClientInfer类型没有正确地从as const断言后的契约对象中提取端点类型信息,导致客户端方法失去了类型检查和自动补全能力。让我们一步步分析并解决这个问题。

问题根源分析

你的核心问题出在ClientInfer类型和buildClient函数的泛型约束上:

  1. 当使用as const断言后,契约对象的类型会变成深层的只读字面量类型,但你的ClientInfer类型没有正确处理这种嵌套结构与Endpoint类型的匹配。
  2. buildClient的泛型const T没有明确约束为契约的结构,TypeScript无法正确递归解析每个属性是否为Endpoint类型。

修正后的完整实现

下面是修复后的代码,我会标注关键的修改点:

import { z } from 'zod';

/**
 * Extracts path parameters (e.g., ":id") from a URL path string.
 */
type ExtractParams<Path extends string> = 
  Path extends `${infer _Start}:${infer Param}/${infer Rest}` 
    ? { [K in Param]: string } & ExtractParams<`/${Rest}`> 
    : Path extends `${infer _Start}:${infer Param}` 
      ? { [K in Param]: string } 
      : {};

/**
 * Defines the structure of a single API endpoint.
 * It uses a conditional type to make `pathParams` required only if params exist in the path.
 */
type Endpoint<
  TPath extends string,
  TMethod extends string,
  TQuery = unknown,
  TBody = unknown,
  TResponse = unknown
> = 
  { 
    method: TMethod; 
    path: TPath; 
    query?: z.ZodSchema<TQuery>; 
    body?: z.ZodSchema<TBody>; 
    response: z.ZodSchema<TResponse>; 
  } & 
  (keyof ExtractParams<TPath> extends never 
    ? { pathParams?: never } 
    : { pathParams: z.ZodSchema<ExtractParams<TPath>> });

/**
 * A helper function to provide type inference for endpoints.
 */
function endpoint<
  TPath extends string,
  TMethod extends string,
  TQuery,
  TBody,
  TResponse
>(
  def: Endpoint<TPath, TMethod, TQuery, TBody, TResponse>
): Endpoint<TPath, TMethod, TQuery, TBody, TResponse> {
  return def;
}

/**
 * This is the desired type for a callable endpoint function on the final client.
 * The arguments should be properly typed.
 */
type EndpointFetcher<T extends Endpoint<any, any, any, any, any>> = (
  args: 
    & (T['body'] extends z.ZodSchema<infer TBody> ? { body: TBody } : { body?: never })
    & (T['query'] extends z.ZodSchema<infer TQuery> ? { query: TQuery } : { query?: never })
    & ('pathParams' extends keyof T ? (T['pathParams'] extends z.ZodSchema<infer TPathParams> ? { pathParams: TPathParams } : never) : { pathParams?: never })
) => Promise<z.infer<T['response']>>;

/**
 * 修正后的递归客户端类型:
 * 1. 增加对只读类型的兼容(处理`as const`断言)
 * 2. 明确判断属性是否为`Endpoint`类型的只读版本
 */
type ClientInfer<T> = 
  T extends Readonly<Endpoint<infer Path, infer Method, infer Query, infer Body, infer Response>>
    ? EndpointFetcher<Endpoint<Path, Method, Query, Body, Response>>
    : T extends Readonly<Record<string, any>>
      ? { readonly [K in keyof T]: ClientInfer<T[K]> }
      : never;

/**
 * 修正后的buildClient函数:
 * 1. 明确泛型约束为契约结构
 * 2. 使用Readonly确保兼容`as const`断言的类型
 */
declare function buildClient<const T extends Record<string, any>>(
  contract: T, 
  config: { baseURL: string }
): ClientInfer<T>;

// -------------------------------------------
// Example Contract & Usage
// -------------------------------------------
const usersContract = {
  // A simple endpoint
  getUser: endpoint({
    method: 'GET',
    path: '/users/:id',
    pathParams: z.object({ id: z.string() }),
    response: z.object({ id: z.string(), name: z.string() }),
  }),
  // A nested endpoint
  updateUser: {
    location: endpoint({
      method: 'PUT',
      path: '/users/:id/location/:locationId',
      pathParams: z.object({ id: z.string(), locationId: z.string() }),
      body: z.object({ name: z.string() }),
      response: z.object({ success: z.boolean() }),
    }),
  },
} as const;

const api = buildClient(usersContract, { baseURL: 'https://api.example.com' });

// 现在会有正确的类型检查和自动补全!
const user = await api.getUser({ 
  pathParams: { id: 'user-123' } 
});

// 嵌套端点也能正常工作
const result = await api.updateUser.location({ 
  pathParams: { id: 'user-123', locationId: 'loc-456' },
  body: { name: 'New Location' } 
});

关键修改点说明

  1. ClientInfer类型的修正:

    • 增加了Readonly包装,处理as const断言后的只读类型,确保能正确匹配Endpoint类型。
    • 在判断端点类型时,显式提取Endpoint的泛型参数,避免TypeScript在递归解析时丢失类型信息。
  2. EndpointFetcher类型的优化:

    • 对pathParams、query、body的类型推断做了更精确的处理,直接从Zod Schema中提取类型,而不是依赖原始的泛型参数。
  3. buildClient函数的泛型约束:

    • 明确泛型T为Record<string, any>,帮助TypeScript更好地推断嵌套结构。
    • 配置参数增加了baseURL的明确类型,让API更清晰。

验证效果

现在你的api.getUser和api.updateUser.location会获得完整的类型支持:

  • 必须传入符合要求的pathParams(如果路径有参数)
  • 请求体和查询参数会根据Zod Schema得到类型检查
  • 返回值会自动推断为Zod Schema对应的响应类型
  • 所有参数都有自动补全提示

内容来源于stack exchange

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.08 10:40:28