diff --git a/docs/required-field-inference.md b/docs/required-field-inference.md new file mode 100644 index 0000000..fcaed59 --- /dev/null +++ b/docs/required-field-inference.md @@ -0,0 +1,201 @@ +# Query API 类型推断优化 - 支持 JSON Schema Required 字段 + +## 问题描述 + +之前的 `query-api` 实现对所有参数使用 `Partial`,导致即使 JSON Schema 中定义了 `required` 字段,TypeScript 类型推断也会将所有字段标记为可选。 + +示例: +```typescript +const api = { + update: { + metadata: { + args: { + data: { + type: "object", + properties: { + id: { type: "string" }, + domain: { type: "string" } + }, + required: ["domain"] // 只有 domain 是必需的 + } + } + } + } +} as const; + +// 之前:所有字段都是可选的 +// queryApi.update({ data: {} }) // 不会报错,但应该要求 domain + +// 现在:正确识别必需字段 +queryApi.update({ data: {} }) // 报错:缺少必需字段 "domain" +queryApi.update({ data: { domain: "test.com" } }) // 正确 +``` + +## 解决方案 + +### 1. 增强 `InferFromJSONSchema` 类型 + +更新类型推断逻辑以支持 `required` 字段: + +```typescript +type InferFromJSONSchema = + // ... 其他类型处理 ... + + // 对象类型 - 带 required 字段(必需字段 + 可选字段) + T extends { type: "object"; properties: infer P; required: infer R extends readonly string[] } + ? { + // 必需字段 + [K in keyof P as K extends R[number] ? K : never]: InferFromJSONSchema + } & { + // 可选字段 + [K in keyof P as K extends R[number] ? never : K]?: InferFromJSONSchema + } + : // 对象类型 - 不带 required 字段(所有字段可选) + T extends { type: "object"; properties: infer P } + ? { + [K in keyof P]?: InferFromJSONSchema + } + : ... +``` + +### 2. 移除不必要的 `Partial` + +从以下位置移除 `Partial` 包装: +- `ApiMethods` 类型定义 +- `QueryApi.post()` 方法 +- `createApi()` 方法内部 + +### 3. 支持 additionalProperties + +添加对动态对象的支持: +```typescript +// 对象类型 - additionalProperties +T extends { type: "object"; additionalProperties: infer A } +? (A extends {} ? Record : never) +: ... +``` + +## 类型推断示例 + +### 示例 1:必需字段和可选字段 + +```typescript +const api = { + update: { + metadata: { + args: { + data: { + type: "object", + properties: { + id: { type: "string" }, + domain: { type: "string" }, + status: { + type: "string", + enum: ["active", "inactive"] + } + }, + required: ["domain"] + } + } + } + } +} as const; + +const queryApi = createQueryApi({ api }); + +// ✅ 正确:只传必需字段 +queryApi.update({ data: { domain: "test.com" } }); + +// ✅ 正确:传必需字段 + 可选字段 +queryApi.update({ + data: { + domain: "test.com", + id: "123", + status: "active" + } +}); + +// ❌ 错误:缺少必需字段 +queryApi.update({ data: { id: "123" } }); +// Error: 类型 "{ id: string; }" 中缺少属性 "domain" + +// ❌ 错误:enum 值不正确 +queryApi.update({ + data: { + domain: "test.com", + status: "pending" + } +}); +// Error: 不能将类型 "pending" 分配给类型 "active" | "inactive" +``` + +### 示例 2:没有 required 字段(全部可选) + +```typescript +const api = { + list: { + metadata: { + args: { + data: { + type: "object", + properties: { + page: { type: "number" }, + pageSize: { type: "number" } + } + // 没有 required 字段 + } + } + } + } +} as const; + +const queryApi = createQueryApi({ api }); + +// ✅ 所有字段都是可选的 +queryApi.list({ data: {} }); +queryApi.list({ data: { page: 1 } }); +queryApi.list({ data: { page: 1, pageSize: 20 } }); +``` + +### 示例 3:动态对象 (additionalProperties) + +```typescript +const api = { + createMeta: { + metadata: { + args: { + metadata: { + type: "object", + additionalProperties: {} // 动态键值对 + } + } + } + } +} as const; + +const queryApi = createQueryApi({ api }); + +// ✅ 可以传入任意键值对 +queryApi.createMeta({ metadata: { key1: "value1", key2: 123 } }); +``` + +## 测试 + +运行测试文件验证类型推断: +```bash +npx tsc --noEmit test/verify-fix.ts +``` + +## 文件修改 + +- `src/query-api.ts` - 更新类型推断逻辑 +- `test/verify-fix.ts` - 测试用例 +- `test/type-test.ts` - 类型推断测试 +- `test/debug-type.ts` - 调试类型推断 + +## 注意事项 + +1. **as const** - API 定义必须使用 `as const` 以保持字面量类型 +2. **readonly** - 类型推断需要处理 `readonly` 修饰符 +3. **enum** - 自动推断 enum 值并提供类型约束 +4. **构建** - 修改后需要重新构建项目:`npm run build` diff --git a/docs/type-inference-fix-report.md b/docs/type-inference-fix-report.md new file mode 100644 index 0000000..c6c1e60 --- /dev/null +++ b/docs/type-inference-fix-report.md @@ -0,0 +1,183 @@ +# 类型推断问题修复报告 + +## 问题描述 + +在 `/home/ubuntu/kevisual/query/test/examples.ts` 中,使用 `as const` 定义的 JSON Schema 对象,其 `required` 字段没有被正确识别。具体表现为: + +- Schema 定义:`required: ["domain"]` - 表示只有 `domain` 是必需字段 +- 实际推断:所有字段(id, domain, appId, status, data)都被推断为必需字段 +- 期望行为:只有 `domain` 是必需的,其他字段应该是可选的 + +## 根本原因 + +问题出在 [src/query-api.ts](../src/query-api.ts) 的 `InferFromJSONSchema` 类型定义中,**条件类型的匹配顺序不当**。 + +### 原始代码(有问题的版本) + +```typescript +type InferFromJSONSchema = + // ... 其他类型 ... + + // 对象类型 - 只有 additionalProperties(纯动态对象) + T extends { type: "object"; additionalProperties: infer A } + ? (A extends {} ? Record : never) + + : // 对象类型 - 带 required 字段(必需字段 + 可选字段) + T extends { type: "object"; properties: infer P; required: infer R extends readonly string[] } + ? { /* ... */ } + + : // ... +``` + +### 问题分析 + +当 JSON Schema **同时包含** `additionalProperties: false` 和 `required: ["domain"]` 时: + +1. TypeScript 首先尝试匹配第一个条件:`T extends { type: "object"; additionalProperties: infer A }` +2. 匹配成功!因为 Schema 确实有 `additionalProperties: false` +3. 此时 `A = false`,而 `false extends {}` 为 `false` +4. 返回 `never`,导致类型推断失败或行为异常 + +**关键点**:在 JSON Schema 规范中,`additionalProperties: false` 和 `required` 字段是可以同时存在的,但原始代码的条件顺序导致 `additionalProperties` 优先匹配,阻止了 `required` 字段的正确处理。 + +## 解决方案 + +调整条件类型的匹配顺序,将 `properties + required` 的检查放在 `additionalProperties` 之前: + +```typescript +type InferFromJSONSchema = + // ... 其他类型 ... + + : // 对象类型 - 带 required 字段(必需字段 + 可选字段) + // 注意:必须在 additionalProperties 检查之前,因为两者可能同时存在 + T extends { type: "object"; properties: infer P; required: infer R extends readonly string[] } + ? { + [K in keyof P as K extends R[number] ? K : never]: InferFromJSONSchema + } & { + [K in keyof P as K extends R[number] ? never : K]?: InferFromJSONSchema + } + + : // 对象类型 - 不带 required 字段(所有字段可选) + T extends { type: "object"; properties: infer P } + ? { + [K in keyof P]?: InferFromJSONSchema + } + + : // 对象类型 - 只有 additionalProperties(纯动态对象) + T extends { type: "object"; additionalProperties: infer A } + ? (A extends false ? Record : Record) + + : // ... +``` + +### 关键改进 + +1. **优先级调整**:将 `properties + required` 的检查提前 +2. **additionalProperties 处理优化**: + - `A extends false` → `Record`(空对象) + - 其他情况 → `Record`(动态对象) + +## 验证结果 + +### 测试场景 1: 带 required + additionalProperties: false + +```typescript +const schema = { + "type": "object", + "properties": { + "id": { "type": "string" }, + "domain": { "type": "string" }, + "appId": { "type": "string" }, + "status": { "type": "string", "enum": ["active", "inactive"] } + }, + "required": ["domain"], + "additionalProperties": false +} as const; +``` + +**推断结果**: +```typescript +type Result = { + domain: string; // ✓ 必需字段 + id?: string; // ✓ 可选字段 + appId?: string; // ✓ 可选字段 + status?: "active" | "inactive"; // ✓ 可选字段 + enum 推断正确 +} +``` + +### 测试场景 2: 所有字段可选 + +```typescript +const schema = { + "type": "object", + "properties": { + "id": { "type": "string" }, + "domain": { "type": "string" } + }, + "additionalProperties": false +} as const; +``` + +**推断结果**: +```typescript +type Result = { + id?: string; // ✓ 可选 + domain?: string; // ✓ 可选 +} +``` + +### 测试场景 3: 所有字段必需 + +```typescript +const schema = { + "type": "object", + "properties": { + "id": { "type": "string" }, + "domain": { "type": "string" } + }, + "required": ["id", "domain"] +} as const; +``` + +**推断结果**: +```typescript +type Result = { + id: string; // ✓ 必需 + domain: string; // ✓ 必需 +} +``` + +## TypeScript 类型系统的关键知识点 + +### 1. `as const` 的作用 + +使用 `as const` 后: +- `required: ["domain"]` 的类型从 `string[]` 变为 `readonly ["domain"]` +- `R[number]` 从 `string`(匹配所有字符串键)变为 `"domain"`(只匹配字面量) +- 这使得 `K extends R[number]` 的判断更加精确 + +### 2. 条件类型的匹配顺序 + +TypeScript 的条件类型是**从上到下**匹配的,**第一个匹配的条件会被使用**。因此: +- 更具体的条件应该放在前面 +- 更通用的条件应该放在后面 +- 可能同时满足多个条件时,要考虑优先级 + +### 3. `extends` 的结构类型检查 + +```typescript +T extends { type: "object"; additionalProperties: infer A } +``` + +即使 `T` 还有其他字段(如 `properties`、`required`),只要它包含 `type` 和 `additionalProperties`,这个条件就会匹配成功。 + +## 总结 + +这个问题的核心是 **TypeScript 条件类型的匹配顺序问题**。当 JSON Schema 同时包含多个字段时,需要确保更具体的条件(如 `properties + required`)在更通用的条件(如只检查 `additionalProperties`)之前被检查。 + +修复后的类型系统现在能够正确处理: +- ✅ 必需字段和可选字段的区分 +- ✅ `as const` 的字面量类型推断 +- ✅ `enum` 字段的联合类型推断 +- ✅ `additionalProperties` 的正确处理 +- ✅ 各种 JSON Schema 字段组合 diff --git a/package.json b/package.json index e3a9c42..1bec79a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kevisual/query", - "version": "0.0.44", + "version": "0.0.46", "type": "module", "scripts": { "build": "npm run clean && bun run bun.config.ts", diff --git a/src/query-api.ts b/src/query-api.ts index 8a35ab4..be954f3 100644 --- a/src/query-api.ts +++ b/src/query-api.ts @@ -25,18 +25,35 @@ type InferFromJSONSchema = T extends { type: "number" } ? number : T extends { type: "integer" } ? number : T extends { type: "boolean" } ? boolean : - // 对象类型 - 检查是否有 properties - T extends { type: "object"; properties: infer P } - ? { - [K in keyof P]: InferFromJSONSchema - } - : // 数组类型 + // 数组类型 T extends { type: "array"; items: infer I } ? Array> + : // 对象类型 - 带 required 字段(必需字段 + 可选字段) + // 注意:必须在 additionalProperties 检查之前,因为两者可能同时存在 + T extends { type: "object"; properties: infer P; required: infer R extends readonly string[] } + ? { + [K in keyof P as K extends R[number] ? K : never]: InferFromJSONSchema + } & { + [K in keyof P as K extends R[number] ? never : K]?: InferFromJSONSchema + } + : // 对象类型 - 不带 required 字段(所有字段可选) + T extends { type: "object"; properties: infer P } + ? { + [K in keyof P]?: InferFromJSONSchema + } + : // 对象类型 - 只有 additionalProperties(纯动态对象) + T extends { type: "object"; additionalProperties: infer A } + ? (A extends false ? Record : Record) : // 默认情况 - 如果是对象但没有 type 字段,尝试递归推断 T extends Record - ? T extends { properties: infer P } - ? { [K in keyof P]: InferFromJSONSchema } + ? T extends { properties: infer P; required: infer R extends readonly string[] } + ? { + [K in keyof P as K extends R[number] ? K : never]: InferFromJSONSchema + } & { + [K in keyof P as K extends R[number] ? never : K]?: InferFromJSONSchema + } + : T extends { properties: infer P } + ? { [K in keyof P]?: InferFromJSONSchema } : unknown : unknown; diff --git a/test/examples.ts b/test/examples.ts index 39c4f58..9de107b 100644 --- a/test/examples.ts +++ b/test/examples.ts @@ -38,15 +38,229 @@ const api = { "url": "/api/router", "source": "query-proxy-api" } + }, + /** + * 获取域名列表,支持分页 + * + * @param data - Request parameters + * @param data.data - {object} + */ + "list": { + "path": "app_domain_manager", + "key": "list", + "description": "获取域名列表,支持分页", + "metadata": { + "args": { + "data": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "page": { + "type": "number" + }, + "pageSize": { + "type": "number" + } + }, + "additionalProperties": false + } + }, + "viewItem": { + "api": { + "url": "/api/router" + }, + "type": "api", + "title": "路由" + }, + "url": "/api/router", + "source": "query-proxy-api" + } + }, + /** + * 更新一个域名的信息 + * + * @param data - Request parameters + * @param data.data - {object} + */ + "update": { + "path": "app_domain_manager", + "key": "update", + "description": "更新一个域名的信息", + "metadata": { + "args": { + "data": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "appId": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + }, + "data": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "domain" + ], + "additionalProperties": false + } + }, + "viewItem": { + "api": { + "url": "/api/router" + }, + "type": "api", + "title": "路由" + }, + "url": "/api/router", + "source": "query-proxy-api" + } + }, + /** + * 删除一个域名 + * + * @param data - Request parameters + * @param data.data - {object} + */ + "delete": { + "path": "app_domain_manager", + "key": "delete", + "description": "删除一个域名", + "metadata": { + "args": { + "data": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "domain": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "viewItem": { + "api": { + "url": "/api/router" + }, + "type": "api", + "title": "路由" + }, + "url": "/api/router", + "source": "query-proxy-api" + } + } + }, + "app": { + "getDomainApp": { + "path": "app", + "key": "getDomainApp", + "metadata": { + "viewItem": { + "api": { + "url": "/api/router" + }, + "type": "api", + "title": "路由" + }, + "url": "/api/router", + "source": "query-proxy-api" + } + } + }, + "app-domain": { + "create": { + "path": "app-domain", + "key": "create", + "metadata": { + "viewItem": { + "api": { + "url": "/api/router" + }, + "type": "api", + "title": "路由" + }, + "url": "/api/router", + "source": "query-proxy-api" + } + }, + "update": { + "path": "app-domain", + "key": "update", + "metadata": { + "viewItem": { + "api": { + "url": "/api/router" + }, + "type": "api", + "title": "路由" + }, + "url": "/api/router", + "source": "query-proxy-api" + } } } } as const; const queryApi = createQueryApi({ api }); export { queryApi }; -queryApi.app_domain_manager.get({ +// ===== 类型推断示例 ===== + +// ✅ 示例 1:只传必需字段 domain +queryApi.app_domain_manager.update({ data: { - id: '123', - domain: 'example.com' + domain: "example.com" } -}) \ No newline at end of file +}); + +// ✅ 示例 2:传必需字段 + 可选字段 +queryApi.app_domain_manager.update({ + data: { + domain: "example.com", + id: "123", + appId: "app-001", + status: "active", // enum 类型,只能是 'active' 或 'inactive' + data: { custom: "metadata" } + } +}); + +// ✅ 示例 3:list 方法没有 required 字段,所有参数都是可选的 +queryApi.app_domain_manager.list({ + data: { + page: 1, + pageSize: 10 + } +}); + +// ✅ 示例 4:list 方法可以不传参数 +queryApi.app_domain_manager.list({ + data: {} +}); + +// 检查类型推断 +type UpdateParams = Parameters[0]; +// 推断为: { data: { domain: string } & { id?: string, appId?: string, status?: "active" | "inactive", data?: Record } } + +type ListParams = Parameters[0]; +// 推断为: { data: { page?: number, pageSize?: number } } + diff --git a/test/json-schema-examples.ts b/test/json-schema-examples.ts index 9e4ad0c..76f7400 100644 --- a/test/json-schema-examples.ts +++ b/test/json-schema-examples.ts @@ -161,4 +161,115 @@ queryApi4.order.updateStatus({ // }); +// ============ 示例 5: Object 类型 - 带 required 字段 ============ +const api5 = { + "domain_manager": { + "update": { + "path": "domain_manager", + "key": "update", + "metadata": { + "args": { + "data": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { "type": "string" }, + "domain": { "type": "string" }, + "appId": { "type": "string" }, + "status": { + "type": "string", + "enum": ["active", "inactive"] as const + }, + "config": { + "type": "object", + "properties": { + "ssl": { "type": "boolean" }, + "cdn": { "type": "boolean" } + } + } + }, + "required": ["domain"] as const, // 只有 domain 是必需的 + "additionalProperties": false + } + } + } + }, + "list": { + "path": "domain_manager", + "key": "list", + "metadata": { + "args": { + "data": { + "type": "object", + "properties": { + "page": { "type": "number" }, + "pageSize": { "type": "number" }, + "status": { + "type": "string", + "enum": ["active", "inactive"] as const + } + } + // 没有 required 字段,所以所有字段都是可选的 + } + } + } + } + } +} as const; + +const queryApi5 = createQueryApi({ api: api5 }); + +// ✅ 示例 5.1: 只传必需字段 domain +queryApi5.domain_manager.update({ + data: { + domain: "example.com" // domain 是必需的 + } +}); + +// ✅ 示例 5.2: 传必需字段 + 可选字段 +queryApi5.domain_manager.update({ + data: { + domain: "example.com", // 必需 + id: "123", // 可选 + appId: "app-001", // 可选 + status: "active", // 可选,且只能是 "active" | "inactive" + config: { // 可选对象 + ssl: true, + cdn: false + } + } +}); + +// ❌ 错误:缺少必需字段 domain +// queryApi5.domain_manager.update({ +// data: { +// id: "123" // 错误:缺少必需字段 "domain" +// } +// }); + +// ✅ 示例 5.3: list 方法所有字段都是可选的 +queryApi5.domain_manager.list({ + data: { + page: 1, + pageSize: 10 + } +}); + +// ✅ 示例 5.4: list 方法可以传空对象 +queryApi5.domain_manager.list({ + data: {} +}); + +// ✅ 示例 5.5: list 方法甚至可以不传参数 +queryApi5.domain_manager.list(); + + +// ============ 类型检查 ============ +type UpdateParams = Parameters[0]; +// 推断为: { data: { domain: string } & { id?: string, appId?: string, status?: "active" | "inactive", config?: {...} } } + +type ListParams = Parameters[0]; +// 推断为: { query?: { page?: number, pageSize?: number, status?: "active" | "inactive" } } + + console.log('✅ 所有类型推断示例运行成功!');