资讯动态

从裸JS迁移到TS 5.7:AI应用开发如何少踩三个坑

发布时间:2026/10/26 5:41:26 来源:尧图企业网站定制
前阵子我把一个AI应用从裸JS重构成了TS 5.7。重构的起因特别朴素连续两周被三个不起眼的问题轮番坑每个问题单拎出来都不算难合在一起就让我意识到裸JS在大模型应用里的松散不是性格问题是会咬人的隐患。这篇文章算是一次经验复盘不是“TypeScript 推广宣言”。我想聊的是在AI应用开发这个具体场景里当你调用大模型、解析返回内容、定义function calling工具、管理一堆API Key和模型名配置的时候TS 5.7为什么能比裸JS少踩三个坑。内容来自我这几个月实际做智能体应用、接多模态模型、处理流式输出的经历适合正在用Node.js写AI应用、或者准备把AI功能接进现有服务的开发者参考。读完你至少能带走三样东西第一三个具体的裸JS翻车现场第二每个翻车现场对应的TS 5.7解法第三一条从裸JS平滑迁到TS 5.7的实操路径包括tsconfig怎么配、哪几类报错最花时间、哪些场景其实不值得上TS。很多人入门大模型开发时看到的第一段代码就是JavaScript这没问题但项目一旦活过三个月类型系统的价值就会显现出来。1. 为什么我在AI应用里把裸JS换成了TS 5.71.1 裸JS写AI应用确实很爽先承认一件事裸JS写AI应用真的很爽。你只要有一台能装Node的机器npm install一个SDK或者干脆用内置的fetch十分钟就能调通一个大模型对话接口。没有构建步骤、没有类型定义、没有tsconfig随便一个脚本文件扔给node就能跑。现在很多大模型开发入门课程的第一课画风基本都是这样一段fetch一个JSON控制台打印出模型的回复。我一开始也是这么干的。这种爽感在demo、原型验证、个人实验阶段完全够用。如果目标只是“给模型发一句话把回复打印出来”裸JS确实比任何带类型系统的方案都更直接因为代码量根本撑不起类型保护的成本。我自己也写过不少这种一次性的调用脚本测prompt、比模型、看返回格式裸JS是真方便。真正出问题的是项目过了那个“玩具阶段”以后。AI应用和普通HTTP服务有个本质区别普通服务里数据结构是你自己定义的是你往外发的AI应用里数据结构有一半是模型返回的是你不可控的。这就形成了一种很拧巴的局面——代码骨架由你写但血肉是模型的而模型会吐出什么东西经常不在你的预期范围里。这时候裸JS的“自由”就变成了双刃剑。1.2 两周之内被坑了三次我的项目是一个智能体应用核心流程是用户提需求模型决定调用哪些工具程序执行工具把结果回传给模型模型再生成最终回答。这个流程跑起来之后头两周我遇到了三个高频问题解析模型返回的JSON时某个字段在一条请求里有、另一条请求里没有代码直接访问这个字段到了运行时才报错。function calling里我发给模型的参数schema和真实函数签名有出入模型按schema生成参数程序用真实函数调用参数对不上功能静默失败。换模型供应商、换本地模型的时候配置里的模型名、baseURL、环境变量到处都是魔法字符串改漏一处就立刻翻车。这三个问题有一个共同点出现概率不低每次都得靠运行时日志定位修复成本不高但特别磨人。当时项目里已经有人在用// ts-check加JSDoc注释做零散的类型检查但都不彻底覆盖不到AI应用真正危险的那些边界。正好那段时间TS 5.7发布我试了试新版本又研究了一轮它改进的递归类型别名推断、模板字符串类型支持把核心代码迁了过去。三周后回头看这三个坑不能说彻底消失但大部分在编译期就被拦截了根本活不到运行时。下面把这三个坑逐一展开每个都附上裸JS的翻车现场和TS 5.7的替代写法。2. 坑一LLM返回的数据结构是“薛定谔的JSON”2.1 同样的接口两次返回两种鬼样子先说最经典的一个。调用OpenAI兼容的chat completion接口SDK返回的回复看起来结构很稳定一个response对象里面有一个choices数组每个元素有messagemessage里有content。很多新手教程甚至只展示这一个形态导致所有人都默认“返回结构就这样”。但你多调几次就会发现message.content经常会变成null——尤其当模型决定调用工具而不是直接说话的时候content直接缺席取而代之的是一个tool_calls数组。再比如choices极少数情况下会是空数组因为请求被内容审核或策略性原因拦住了。如果代码写的是// 裸JS const data JSON.parse(response); const text data.choices[0].message.content;这段代码在模型正常回复时毫无问题但只要某次返回里choices是空数组或者message.content是null它就会在某个奇怪的位置抛出Cannot read properties of undefined。更讨厌的是text被拿到之后还会继续传给UI渲染、塞进数据库等你发现不对劲的时候脏数据已经流出去好几层了。还有一坑大模型经常返回带markdown围栏的JSON字符串。比如你让它“只输出JSON”它偏要给你包一圈json ... 。裸JS直接JSON.parse就抛异常你得先写一个剥围栏的正则。这个不算类型问题但它和类型问题混在一起时排查成本会翻倍。2.2 TS 5.7怎么把这个坑填平TS这边的思路不是“猜对结构”而是“先把不确定的地方亮出来逼你在边界处理”。首先定义一个可辨识联合类型描述模型消息的所有可能形态type ChatMessage | { role: assistant; content: string | null; tool_calls?: ToolCall[] } | { role: user; content: string } | { role: system; content: string }; interface ChatCompletionResponse { id?: string; choices: ChatCompletionChoice[]; // 注意不保证非空 } interface ChatCompletionChoice { index: number; message: ChatMessage; finish_reason: stop | length | tool_calls | content_filter; } interface ToolCall { id: string; type: function; function: { name: string; arguments: string; // 通常是一段JSON字符串 }; }然后解析的地方不要直接断言成any而是用unknown接住再通过类型守卫把结构收窄function parseChatResponse(raw: string): ChatCompletionResponse { const data: unknown JSON.parse(raw); // 这里还可以加一层运行时结构化校验见下文 return data as ChatCompletionResponse; } function isToolCallMessage(msg: ChatMessage): msg is ExtractChatMessage, { role: assistant; tool_calls: ToolCall[] } { return msg.role assistant Array.isArray(msg.tool_calls) msg.tool_calls.length 0; }处理时这样写const data parseChatResponse(raw); const choice data.choices[0]; // 开启noUncheckedIndexedAccess后类型是Choice | undefined if (!choice) throw new Error(响应里没有choices可能被策略拦截); const msg choice.message; if (isToolCallMessage(msg)) { // 走工具调用分支 for (const call of msg.tool_calls) { const args JSON.parse(call.function.arguments); // args仍然可能和你预期不一致但至少外围结构已经稳住了 } } else { // 走普通文本分支content可能是null必须显式处理 const text msg.content ?? ; console.log(text); }这里有个点值得多说一句TS 5.7对递归类型别名的推断改进很大。像ChatMessage这种引用着ToolCall[]、而ToolCall内部结构又有一定复杂度的类型以前嵌套多了容易触发“类型实例化过深”的报错5.7之后这个问题明显变少。也就是说类型可以放心地建深不用老担心编译器先罢工。2.3 边界上再加一层运行时校验当然光声明类型是不够的。data as ChatCompletionResponse说到底是在告诉TS“我说了算”如果真实返回结构跟类型定义完全不符TS也帮不了你。真正稳妥的做法是在数据进入系统的那一刻做一次真实的运行时校验。我在项目里用的是zod和TS类型几乎无缝配合。先定义一个和类型对应的校验schemaimport { z } from zod; const ToolCallSchema z.object({ id: z.string(), type: z.literal(function), function: z.object({ name: z.string(), arguments: z.string() }) }); const ChatMessageSchema z.discriminatedUnion(role, [ z.object({ role: z.literal(assistant), content: z.string().nullable(), tool_calls: z.array(ToolCallSchema).optional() }), z.object({ role: z.literal(user), content: z.string() }), z.object({ role: z.literal(system), content: z.string() }) ]); const ChatCompletionResponseSchema z.object({ id: z.string().optional(), choices: z.array(z.object({ index: z.number(), message: ChatMessageSchema, finish_reason: z.enum([stop, length, tool_calls, content_filter]) })) });解析时先过校验function parseChatResponse(raw: string): ChatCompletionResponse { const data: unknown JSON.parse(raw); return ChatCompletionResponseSchema.parse(data); }这样做的价值是模型返回的结构一旦和预期偏离错误在入口处就暴露了而且zod的报错信息会精确到哪个字段缺失、哪个字段类型不对。后面的业务逻辑就可以放心信任TS类型推导的结果因为运行时和编译期已经对齐了。我推荐的边界处理范式是unknown入口runtime schema校验类型收窄。三步走完后面全是安全的。裸JS省掉这三步等于把风险全堆到业务代码里然后靠一双肉眼去翻日志。2.4 多模态压过来之后结构更不稳定这两年多模态模型进展很快我接到一个需求要处理模型返回的图片、坐标和文本混排内容。这类回复的结构比纯文本更不稳定有的是content里塞一段markdown图片链接有的走image_url字段有的直接返回一个URL数组。在裸JS里每适配一个供应商就要在代码里加一个if (data.xxx)改到后面自己都记不清哪个字段是哪家的了。TS里我用带kind字段的联合类型建模多模态内容type MultimodalBlock | { kind: text; text: string } | { kind: image; url: string; width?: number; height?: number } | { kind: tool_use; toolName: string; args: Recordstring, unknown };模型返回的content就定义成MultimodalBlock[]后续无论是渲染还是入库都通过switch (block.kind)把类型收窄到具体分支。新增一种block类型时编译器会逼着我把所有switch分支补齐不会像裸JS那样漏掉一种情况然后静默吞掉。多模态模型的输出复杂度和不可控性都更高类型系统在这里的作用不是锦上添花而是刚需。3. 坑二Function Calling的Schema和真实函数总在撒谎3.1 schema与函数签名脱节的翻车现场第二个坑来自function calling有些SDK也叫tool calling。当你告诉模型“你可以调用这些函数”时需要把函数的名称、描述、参数schema一起传给模型。模型读到了这个schema返回一个函数名和一段JSON格式的arguments然后你的程序把arguments解析出来调用真实函数。这套流程里最常见的翻车点就是schema里写的参数名和真实函数签名对不上。举个例子// schema里写的字段名 const toolDef { name: update_order_status, parameters: { type: object, properties: { order_id: { type: string }, new_status: { type: string, enum: [pending, shipped, done] } }, required: [order_id, new_status] } }; // 真实函数 function updateOrderStatus(orderId, newStatus) { ... } // 模型按schema生成参数 const args JSON.parse(modelReturn.arguments); // args { order_id: ord_123, new_status: shipped } // 直接调真实函数 updateOrderStatus(args.order_id, args.new_status);裸JS不会报任何错args.order_id就是args.order_id真实函数里拿到的orderId是undefinednewStatus也大概率接错。结果就是用户端看起来工具调用了模型的后续流程也走完了但订单状态根本没更新——静默失败。另一个常见问题是枚举值不匹配。schema里写enum: [pending, shipped, done]真实函数却只用字符串硬比较模型返回一个你没列进去的值比如processing函数还是会执行但行为完全没定义。这种错误在裸JS里只能在业务日志里慢慢抠而且因为代码“没抛错”很少会有人第一时间怀疑是schema和签名不一致。3.2 用TS 5.7把schema和实现绑在一起我的解法是把“函数签名”和“schema”用类型绑定起来让它们永远无法分家。做法是定义一个工具对象里面既带实现也带一个由zod推导出的schemaimport { z } from zod; const UpdateOrderStatusSchema z.object({ orderId: z.string().describe(订单ID), newStatus: z.enum([pending, shipped, done]).describe(新的订单状态) }); type UpdateOrderStatusArgs z.infertypeof UpdateOrderStatusSchema; function updateOrderStatus(orderId: string, newStatus: UpdateOrderStatusArgs[newStatus]) { // 业务实现orderId和newStatus都是严格类型 } const updateOrderStatusTool { name: update_order_status, description: 更新指定订单的状态, parameters: UpdateOrderStatusSchema, runSafe: (argsText: string) { const args UpdateOrderStatusSchema.parse(JSON.parse(argsText)); return updateOrderStatus(args.orderId, args.newStatus); } };重点在这里schema和运行入口在同一个对象里字段名从同一个类型推导出来。模型返回的参数经过schema.parse校验字段名不对立刻抛错不会等函数内部默默吞掉。每个工具自己负责解析自己的参数不存在一个通用runner去猜每个参数结构。如果你愿意还可以用z.function()把参数校验和执行结构写成一个整体const saferTool z.function() .args(UpdateOrderStatusSchema) .returns(z.promise(z.object({ ok: z.boolean() }))) .implement(updateOrderStatus);这样“schema、校验、执行”是一条链中间没有任何脱节的地方。TS 5.7配合zod重点不是“解析一遍”而是让schema和真实函数天然就是同一份定义的两种视图想不一致都难。3.3 智能体批量注册工具时这招尤其值智能体应用里工具通常不止一个。很多项目用一个tools数组把所有工具定义塞进去裸JS看起来没什么问题但工具多了维护就成了灾难给模型看的schema是一份真实函数的签名是另一份文档写的是第三份三份内容稍有分歧模型就开始“幻觉”出不存在的参数。TS的做法是把工具定义收敛成一个数组每个元素都是“schema、类型、实现”的三位一体const tools [ updateOrderStatusTool, createOrderTool, queryOrderTool ]; function dispatchTool(name: string, argsText: string) { const tool tools.find(t t.name name); if (!tool) throw new Error(未知工具: ${name}); return tool.runSafe(argsText); // 每个工具自己负责解析参数 }新增工具时只要照葫芦画瓢加一个对象把schema和runSafe写在一起就不可能出现“别的文件里手写了一份参数定义跟实际函数对不上”的情况。这个结构在智能体应用里特别常见也是我体会最深的收益类型保护的是“实现和描述的一致性”而不是简单的不报错。4. 坑三环境配置和模型名的“魔法字符串”4.1 魔法字符串造成的两起事故AI应用里魔法字符串尤其多模型名、baseURL、API Key、temperature、max_tokens、system prompt……这些东西几乎没有一个是代码从内而外能推导出来的全是从配置里读的。裸JS项目通常的做法是const config { model: gpt-4o, baseURL: process.env.OPENAI_BASE_URL || https://api.openai.com/v1, apiKey: process.env.OPENAI_API_KEY, temperature: 0.7 };看起来挺规整但有两类问题会在实际运行中反复出现。第一类是模型名和baseURL拼写错误。比如本地跑OllamabaseURL你写成http://localhost:11434/v1但实际服务对路径的处理和SDK的预期不完全一致多点一个斜杠都可能404。再比如把gpt-4o拼成gpt-4o-mini或者把供应商的模型名记错API直接回400。裸JS里这些错误只有发请求那一刻才暴露每次都要翻日志。第二类是环境变量缺失。process.env.OPENAI_API_KEY如果没设置裸JS里它就是undefinedSDK发请求时才会抛出“Missing API key”。有时候更麻烦一个团队里有人用本地配置跑通了另一个人没配结果别人一跑就报错最后发现是环境变量名大小写或者下划线位置写错了。4.2 字面量类型和模板字符串类型把错误拦在编译期TS的写法不是去验证运行时而是让“不该出现的情况”根本过不了编译。最基础的是用一个联合类型把模型名固定住type ModelName gpt-4o | gpt-4o-mini | claude-3-5-sonnet | qwen-max; interface AiConfig { model: ModelName; baseUrl: https://${string} | http://localhost:${string}; maxTokens: number; } const config { model: gpt-4o, // 合法 baseUrl: https://api.openai.com/v1, // 合法 maxTokens: 2048 } satisfies AiConfig;如果你把model写成gpt4o或者把baseUrl写成api.openai.com/v1少了https://satisfies会直接在编译期报错。这个错误发生的时候可能离真正发请求还有很远但它已经在源头把写错的名字拦住了。TS 5.7对模板字符串类型的支持更成熟了。其实模板字符串类型很早就有了但以前在很多嵌套场景下表现不太稳定5.7之后用起来顺了很多。你可以把URL规范化写成一个小类型约束type ApiBaseUrl https://${string}/v1;这样凡是baseUrl不满足https://开头、不以/v1结尾的值都会在类型层面被拦下来。当然这种约束可以根据实际情况放宽关键是让配置写入时先拦住低级拼写错误真到运行时配置还是要过一层“缺失即报错”的检查。4.3 环境变量的非空保证环境变量的处理上TS有一个特别戳中我的点开启strictNullChecks之后process.env.XXX的类型是string | undefined。这其实是在逼你使用前先判断而判断本身就是一种防御。function getEnv(name: string): string { const value process.env[name]; if (!value) { throw new Error(缺少环境变量: ${name}); } return value; } export const env { openaiApiKey: getEnv(OPENAI_API_KEY), openaiBaseUrl: process.env.OPENAI_BASE_URL ?? https://api.openai.com/v1 };这样环境变量只被读取一次缺失就立刻暴露在应用启动时而不是等到第一次调模型时才抛异常。这个模式后来我在私有化AI应用场景也用上了——那些在NAS这类设备上跑本地模型的朋友环境变量和配置往往写在docker-compose或系统面板里少配一个、配错一个TS这层检查能让你在启动阶段就看到而不是半夜爬起来看日志。5. 迁移实操从裸JS到TS 5.7的完整过程5.1 渐进式迁移配置方案如果项目已经在跑了别想着推翻重写渐进迁移是稳妥路线。第一步先建tsconfig核心配置如下{ compilerOptions: { target: ES2022, module: NodeNext, moduleResolution: NodeNext, strict: true, noUncheckedIndexedAccess: true, noImplicitOverride: true, allowJs: true, checkJs: false, outDir: dist }, include: [src] }allowJs: true意味着你可以保留原有.js文件用.ts文件逐步替换。新文件直接写成.ts并享受所有严格检查旧文件暂时不碰等有需求时再改。checkJs: false是为了不让TS去检查你还没打算动工的JS文件——如果一开始就改成true历史报错会砸你一脸容易劝退。我实际迁移的顺序是先给最核心的数据边界上类型——解析模型响应、解析工具参数、读取配置这几处再给工具注册表上类型最后才是业务逻辑里的细枝末节。这个过程会暴露出一些裸JS时期被掩盖的问题比如某个变量在某些分支下真的是undefined只是之前没有触发。5.2 迁移中比较难缠的几类类型报错说说个人体会迁TS时最花时间的不是语法报错而是三类“业务惯性”问题。第一类是隐式any。以前裸JS写function foo(obj)变成.ts以后TS要求obj有类型。如果一时不知道确切结构可以先写Recordstring, unknown顶着然后尽快找出真实结构替换不要让any过夜。any一旦泛滥类型保护就形同虚设。第二类是“可能为null”的连锁反应。严格模式下data.choices[0]可能是undefinedmsg.content可能是null。这些值一旦流出边界所有下游消费方都要求判空。处理方法是找到源头把“不确定结构”统一在边界处收窄就像前面parseChatResponse那样出了边界之后不要再靠any绕过。第三类是和SDK自带类型打架。OpenAI、Anthropic这些官方SDK基本都自带TypeScript类型如果项目里自己手写了接口定义就可能出现字段名差异导致类型不兼容。建议少自己定义“雷同类型”优先import type { ChatCompletion } from openai/resources/chat/completions这种方式拿SDK的类型作为模型返回类型再叠加zod做运行时校验。5.3 哪些AI应用场景不值得上TS我不敢说所有项目都该迁。如果项目是完全的一次性脚本、临时demo、或者只是为了验证某个prompt效果裸JS就够了。给这种场景引入TS花在类型定义上的时间可能比写业务逻辑还多完全划不来。还有一种折中方案不想引入构建步骤又想要一点类型检查可以用// ts-check加JSDoc注释。VSCode会基于JSDoc做一定程度的类型推断配合checkJs: true算是“抹一点TS酱的裸JS”。但坦白说AI应用一旦涉及function calling和复杂消息结构JSDoc那种写法会变得很啰嗦撑不了太久。我的判断标准很简单如果这个AI应用要活过三个月、要接超过一个模型供应商、会被不止一个人维护就该上TS。如果只是今晚写个脚本跑一次裸JS没毛病没必要为了仪式感引入一堆工具链。6. 迁移之后我给自己定的一条纪律迁移完TS 5.7之后我又给自己加了一条纪律所有对外部世界不信任的边界——模型返回、工具参数、配置文件——都必须经过“unknown、校验、收窄”这个流程不允许在边界以外出现as any或者裸断言。这样做以后TS的类型不再是装饰而是真实承担了运行时防御的一部分职责。我常被问到“AI应用开发面试时会不会考类型系统”。我的回答是类型系统本身不是考点但它是衡量AI应用代码质量的一个重要指标。面试官看你的AI项目代码如果满屏都是any和裸断言第一印象会打折扣反过来能在数据边界把类型收得干干净净说明你对“模型输出不可控”这件事有清醒的认知。个人感受是TS 5.7和AI应用组合在一起挺搭的。AI应用开发里别人帮你兜底的东西少——模型不可控、网络不可控、第三方SDK不可控你能控制的只有自己这边的代码边界。把边界用类型焊死少掉的这三个坑基本等于少熬三个深夜。这就是我这次迁移最大的收获。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑