{"slug": "typescript-for-ai-engineers-the-language-of-ai-web-apps", "title": "TypeScript for AI Engineers: The Language of AI Web Apps", "summary": "A tutorial published for AI engineers shows Python programmers how to build a typed, tested TypeScript client for a chat API, with streaming, using Node 22 and TypeScript 6.0.3. The guide walks through installing typescript, tsx and @types/node, configuring tsconfig.json with \"strict\": true, and separating `npx tsx` to run files from `npx tsc --noEmit` to type-check them, noting the React, Next.js and ESLint ecosystem sections were described from knowledge rather than installed. It argues the browser should never hold the model API key, so a server in TypeScript or Python sits between the chat UI and the model API.", "body_md": "You can build a great AI career in Python alone. But people use web pages, and the chat boxes, dashboards, and prompt playgrounds on those pages are usually built with **TypeScript**. Learn it, and you can ship the whole product yourself.\n\nThis post is core. It's a fast path for Python programmers, not a full course, and by the end you'll have written a typed, tested client for the API from Post 20, streaming included.\n\nEverything here ran on Node 22 and TypeScript 6.0.3, so the outputs and error messages are real. The exception is the ecosystem section (React, Next.js, ESLint), which I describe from knowledge because I couldn't install those here.\n\n## Why TypeScript, and what it actually is\n\n**JavaScript** is the language that runs in every web browser, and, through a program called **Node.js**, on servers too. It's flexible and a bit loose, which is great for small scripts and painful for large projects.\n\n**TypeScript** is JavaScript *plus types*. You write type annotations, much like the type hints from Post 10, and a tool called the compiler (`tsc`) checks them and catches mistakes before your code runs. Then the types are **erased**, leaving plain JavaScript that runs anywhere. That last detail has big consequences, and we'll get to them later.\n\nWhere does it fit in an AI app? Here's the usual shape:\n\n``` php\nbrowser (TypeScript/React)  -->  your server (TypeScript or Python)  -->  model API\n         the chat screen              holds the API key, adds logic           the model\n```\n\nThe browser should never hold your API key (Post 20), so your server sits in the middle, keeps the secrets, and streams results back. That server can be TypeScript or Python, and the pieces talk to each other over HTTP, which you already understand.\n\n## Setting up\n\nYou need Node.js (get the LTS version from nodejs.org). Check it:\n\n```\nnode --version\nnpm --version\n```\n\n`npm` is Node's package manager. It does for JavaScript what `pip` and `uv` do for Python. Create a project:\n\n```\nmkdir chat-api-client\ncd chat-api-client\nnpm init -y\nnpm install --save-dev typescript tsx @types/node\n```\n\nThat installs three development tools:\n\n- `typescript` is the compiler and type checker.\n- `tsx` runs TypeScript files directly, without a separate build step. It's the easiest way to run TS while learning.\n- `@types/node` teaches TypeScript about Node's built-in features.\n\nNotice a folder called `node_modules/` appears, full of installed packages, along with `package.json` (your project's description and dependencies, like `pyproject.toml` from Post 12) and `package-lock.json` (the exact versions installed, like `uv.lock`). **Commit the lock file, and add** `node_modules/` **to your** `.gitignore` (Post 22). `node_modules` can be enormous, and anyone can rebuild it by running `npm install`.\n\nAdd `\"type\": \"module\"` to `package.json`, so your files use the modern import syntax. And create a `tsconfig.json` in the project folder to configure the compiler:\n\n```\n{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"module\": \"NodeNext\",\n    \"moduleResolution\": \"NodeNext\",\n    \"strict\": true,\n    \"noEmit\": true,\n    \"skipLibCheck\": true,\n    \"lib\": [\"ES2022\", \"DOM\", \"DOM.Iterable\"],\n    \"types\": [\"node\"]\n  },\n  \"include\": [\"src\"]\n}\n```\n\nThe most important line is `\"strict\": true`. It turns on the strictest checks, including the one that catches `null` and `undefined` mistakes. Always use it. TypeScript's defaults change between versions, so spelling out your settings, as above, keeps behavior predictable. `noEmit` means \"just check, don't produce files,\" since `tsx` handles running.\n\nNow you have two commands, and the difference between them is important:\n\n```\nnpx tsx src/hello.ts      # RUN the file. Does NOT check types.\nnpx tsc --noEmit          # CHECK the types. Doesn't run anything.\n```\n\n`tsx` strips the types and runs the code, even if there are type errors. `tsc` does the checking. Get in the habit of running the check regularly, and add both to `package.json` so they're easy to remember:\n\n```\n\"scripts\": {\n  \"start\": \"tsx src/client.ts\",\n  \"check\": \"tsc --noEmit\",\n  \"test\": \"tsx --test src/*.test.ts\"\n}\n```\n\nThen `npm run check` and `npm test` do the work. (Newer versions of Node can also run TypeScript files directly by stripping types, but the details depend on your Node version, so I'll stick with `tsx` here.)\n\n## JavaScript for Python programmers\n\nTypeScript *is* JavaScript, so you need the JavaScript basics first. Here's a translation table, and then the gotchas that bite Python programmers.\n\n| Python | TypeScript / JavaScript | \n|---|---|\n| `x = 5` | `const x = 5` (or`let x = 5` if it will change) | \n| `None` | `null` or`undefined` (two kinds of \"nothing\") | \n| `True` /`False` | `true` /`false` | \n| `f\"Hello, {name}\"` | `` `Hello, ${name}` `` (backticks) | \n| `len(items)` | `items.length` | \n| `x in items` | `items.includes(x)` | \n| `for x in items:` | `for (const x of items) { ... }` | \n| `[n * n for n in nums if n % 2 == 0]` | `nums.filter(n => n % 2 === 0).map(n => n * n)` | \n| `{\"a\": 1}` (dict) | `{ a: 1 }` (an object), or a`Map` | \n| `print(x)` | `console.log(x)` | \n| `def f(x): ...` | `function f(x) { ... }` or`const f = (x) => ...` | \n| `try / except` | `try / catch` | \n| `async def` /`await` | `async function` /`await` | \n| `dataclass` / Pydantic | `interface` or`type` , plus Zod for runtime checks | \n| `pip` /`uv` ,`pyproject.toml` | `npm` ,`package.json` | \n| `pytest` | Vitest, Jest, or Node's built-in test runner | \n\nI ran each of the following. Here they are, with the output.\n\n**One number type.** There's no separate `int` and `float`:\n\n```\nconsole.log(typeof 1, typeof 1.5, 0.1 + 0.2, 7 / 2, Math.floor(7 / 2));\n// number number 0.30000000000000004 3.5 3\n```\n\nNote that `7 / 2` is `3.5`, as in Python 3. For whole-number division, use `Math.floor`. The `0.1 + 0.2` quirk from Post 4 is the same.\n\n**Two kinds of \"nothing\".** `null` means \"deliberately empty,\" and `undefined` means \"never set.\" You'll see both:\n\n``` js\nlet a: string | null = null;\nlet b: string | undefined;\nconsole.log(a, b, a == b, a === b);\n// null undefined true false\n```\n\n**Always use** `===`**.** The double-equals `==` converts types before comparing, with surprising results:\n\n```\nconsole.log(0 == \"\", 0 === \"\", \"1\" == 1);\n// true false true\n```\n\n`0 == \"\"` is `true`. Don't use `==`. Happily, TypeScript flags many of these comparisons for you: it reports `This comparison appears to be unintentional because the types 'number' and 'string' have no overlap.`\n\n**Truthiness differs from Python.** An empty array and an empty object are **truthy** in JavaScript, while they're falsy in Python:\n\n```\nconsole.log([] ? \"empty array is truthy\" : \"falsy\");     // empty array is truthy\nconsole.log([0, \"\", null, undefined, NaN].map((v) => Boolean(v)));\n// [ false, false, false, false, false ]\n```\n\nThe falsy values are `false`, `0`, `\"\"`, `null`, `undefined`, and `NaN`. To check for an empty array, test `items.length === 0`. TypeScript even complains about `[] ? ...`, with `This kind of expression is always truthy.`\n\n`??` **versus** `||`**.** This one causes real bugs. `||` falls back for *any* falsy value, including a legitimate `0`. The \"nullish coalescing\" operator `??` falls back only for `null` and `undefined`:\n\n``` js\nconst retries = 0;\nconsole.log(\"with ||:\", retries || 3, \"| with ??:\", retries ?? 3);\n// with ||: 3 | with ??: 0\n```\n\nIf someone sets `retries` to `0` on purpose, `||` quietly turns it into `3`. Use `??` for defaults. Its partner is **optional chaining**, `?.`, which stops safely if something is missing:\n\n``` js\nconst user: { profile?: { email?: string } } = {};\nconsole.log(user.profile?.email, user.profile?.email ?? \"no email\");\n// undefined no email\n```\n\nWithout `?.`, `user.profile.email` would crash, as `None.email` does in Python.\n\n**Array methods** replace comprehensions:\n\n``` js\nconst nums = [1, 2, 3, 4, 5, 6];\nconsole.log(nums.filter((n) => n % 2 === 0).map((n) => n * n));   // [ 4, 16, 36 ]\nconsole.log(nums.reduce((sum, n) => sum + n, 0));                 // 21\nconsole.log(nums.find((n) => n > 3), nums.some((n) => n > 5), nums.every((n) => n > 0));\n// 4 true true\n```\n\n**The sorting gotcha.** `.sort()` with no argument sorts as *text*:\n\n```\nconsole.log([10, 9, 1, 100].sort());                    // [ 1, 10, 100, 9 ]\nconsole.log([10, 9, 1, 100].sort((x, y) => x - y));     // [ 1, 9, 10, 100 ]\n```\n\nAlways pass a comparison function for numbers.\n\n**Destructuring and spread** are lovely, and you'll use them constantly:\n\n```\nconst { role, content } = { role: \"user\", content: \"hi\", extra: 1 };\nconst [first, ...rest] = [1, 2, 3];\nconst merged = { ...{ model: \"x\", temperature: 0.7 }, temperature: 0.2 };\nconsole.log(role, content, first, rest, merged);\n// user hi 1 [ 2, 3 ] { model: 'x', temperature: 0.2 }\n```\n\nThe spread `...` copies an object's fields, and later fields win, so `merged` has `temperature: 0.2`. That's the standard way to apply overrides to default settings.\n\n**Objects as dictionaries, and** `Map`**.** A plain object works for string keys. A `Map` is better when keys vary or when you add and remove a lot:\n\n``` js\nconst counts: Record<string, number> = {};\nfor (const word of \"the cat the dog\".split(\" \")) {\n  counts[word] = (counts[word] ?? 0) + 1;\n}\nconsole.log(counts);     // { the: 2, cat: 1, dog: 1 }\n```\n\nThat's the word counter from Post 5 again, with `??` playing the role of `.get(word, 0)`.\n\n**JSON is built in**, and speaks the language natively:\n\n``` js\nconst text = JSON.stringify({ role: \"user\", content: \"hi\" });\nconsole.log(text, JSON.parse(text).content);\n// {\"role\":\"user\",\"content\":\"hi\"} hi\n```\n\n`JSON.parse` throws a `SyntaxError` on bad input, so wrap it in `try/catch` when the data comes from outside.\n\n**Big numbers lose precision.** All numbers are 64-bit floats, so whole numbers are only exact up to a point:\n\n```\nconsole.log(Number.MAX_SAFE_INTEGER, 9007199254740993, 9007199254740993n);\n// 9007199254740991 9007199254740992 9007199254740993n\n```\n\nThe middle value was written as `...993` but printed as `...992`. It silently changed. The `n` suffix makes a `BigInt`, which is exact. This matters when a database ID or a huge counter passes through JavaScript: keep such IDs as strings.\n\n`for...of` **versus** `for...in`**.** Use `for (const x of items)` to loop over *values*. `for...in` loops over keys, which for an array means the indexes *as strings*, which is almost never what you want:\n\n``` js\nfor (const x of [\"a\", \"b\"]) console.log(\"of:\", x);       // of: a, of: b\nfor (const i in [\"a\", \"b\"]) console.log(\"in:\", i, typeof i);   // in: 0 string, in: 1 string\n```\n\n## TypeScript basics\n\nNow the types. If you've done Post 10, much of this will feel familiar.\n\n### Describing shapes\n\nAn **interface** (or a `type`) describes the shape of an object. Here's a chat message, using a **literal union** for the role, the TypeScript cousin of Python's `Literal`:\n\n```\ntype Role = \"user\" | \"assistant\" | \"system\";\n\ninterface Message {\n  role: Role;\n  content: string;\n  tokens?: number;     // the ? makes it optional\n}\n\nfunction tokenCount(message: Message): number {\n  return message.tokens ?? Math.ceil(message.content.length / 4);\n}\n\nconsole.log(tokenCount({ role: \"user\", content: \"How do generators work?\" }));   // 6\nconsole.log(tokenCount({ role: \"user\", content: \"hi\", tokens: 9 }));              // 9\n```\n\nYou don't have to annotate everything. TypeScript **infers** types, so `const n = 5` is already known to be a number. Annotate function parameters and return types, and let inference handle the rest.\n\n### Letting the compiler catch your mistakes\n\nHere's where it pays off. I wrote a file full of deliberate mistakes using that `Message` type, and ran `tsc`. These are the real messages:\n\n```\nType '\"robot\"' is not assignable to type '\"user\" | \"assistant\" | \"system\"'.\n```\n\nYou gave a role that isn't allowed.\n\n```\nProperty 'content' is missing in type '{ role: \"user\"; }' but required in type 'Message'.\n```\n\nA required field is missing.\n\n```\n'm.tokens' is possibly 'undefined'.\n```\n\nYou used `m.tokens + 1`, but `tokens` is optional. This is the classic one, the cousin of Python's `NoneType has no attribute` error from Post 11, except it's caught *before* you run anything.\n\n```\n'text' is possibly 'null'.\n```\n\nCalling `text.toUpperCase()` when `text` might be `null`.\n\n```\nObject literal may only specify known properties, but 'tokns' does not exist in type 'Message'. Did you mean to write 'tokens'?\n```\n\nA typo in a field name, and TypeScript even suggests the fix.\n\n```\nArgument of type 'string' is not assignable to parameter of type 'number'.\n```\n\nCalling `double(\"21\")` on a function that takes a number.\n\n```\n'value' is of type 'unknown'.\n```\n\nUsing a value of type `unknown` without checking it first (more on that below).\n\n```\nFunction lacks ending return statement and return type does not include 'undefined'.\n```\n\nThis one appeared in a `switch` that forgot a case, which brings us to the next feature.\n\nRead each message from the start: it names the problem, and the position tells you the line. Most are short and clear.\n\n### Unions that tell you what you're holding\n\nA **discriminated union** is a set of shapes, each with a field that says which one it is. It's perfect for streaming events, where a stream can send tokens, errors, or a \"done\" signal:\n\n```\ntype StreamEvent =\n  | { type: \"token\"; token: string }\n  | { type: \"error\"; message: string }\n  | { type: \"done\" };\n\nfunction describe(event: StreamEvent): string {\n  switch (event.type) {\n    case \"token\":\n      return `token: ${event.token}`;\n    case \"error\":\n      return `error: ${event.message}`;\n    case \"done\":\n      return \"done\";\n  }\n}\n```\n\nInside each `case`, TypeScript *narrows* the type: in the `\"token\"` branch, it knows `event` has a `token` field, and in the `\"error\"` branch it knows about `message`. And if you forget a case, as I did on purpose in my error file, the compiler objects with the \"lacks ending return statement\" error above. Add a new event type later, and the compiler shows you every place that needs updating.\n\n### `unknown`, not `any`\n\nTypeScript has a type called `any` that switches checking off completely. **Avoid it.** It defeats the purpose, and it spreads. When you genuinely don't know what something is, like data from `JSON.parse` or a network response, use `unknown`. It means \"could be anything, and you must check before using it\":\n\n```\nfunction isMessage(value: unknown): value is Message {\n  if (typeof value !== \"object\" || value === null) return false;\n  const v = value as Record<string, unknown>;\n  return (\n    (v.role === \"user\" || v.role === \"assistant\" || v.role === \"system\") &&\n    typeof v.content === \"string\"\n  );\n}\n\nconst incoming: unknown = JSON.parse('{\"role\": \"assistant\", \"content\": \"Hello\"}');\nif (isMessage(incoming)) {\n  console.log(\"valid message:\", incoming.content.toUpperCase());   // valid message: HELLO\n}\nconsole.log(isMessage({ role: \"robot\", content: \"x\" }), isMessage(null), isMessage(\"text\"));\n// false false false\n```\n\nThe `value is Message` in the signature makes `isMessage` a **type guard**. When it returns `true`, TypeScript treats the value as a `Message` inside that `if`. It's your own runtime check, with the type system's blessing.\n\n### Generics and handy utilities\n\n**Generics** let a function work with any type while staying type-safe, like `list[T]` in Python's hints:\n\n``` js\nfunction first<T>(items: T[]): T | undefined {\n  return items[0];\n}\n\nconst n = first([1, 2, 3]);       // TypeScript knows: number | undefined\nconst s = first([\"a\", \"b\"]);      // string | undefined\n```\n\nYou'll meet generics constantly in the form `Promise<string>` (\"a promise that will produce a string\"), `Array<number>`, and `Record<string, number>` (an object with string keys and number values). A few more tools you'll use:\n\n``` js\nconst ROLES = [\"user\", \"assistant\", \"system\"] as const;    // a fixed list\ntype RoleFromList = (typeof ROLES)[number];                // \"user\" | \"assistant\" | \"system\"\n\ntype Options = { temperature: number; maxTokens: number; stream: boolean };\nconst overrides: Partial<Options> = { temperature: 0.2 };   // every field optional\n```\n\n`as const` freezes a list into exact literal types, so you can derive a union from it. `Partial<T>` makes every field of `T` optional, which is ideal for \"settings you may override.\"\n\n## Types vanish at runtime\n\nHere's the most important lesson, and it echoes Post 10. TypeScript types exist only while you write code. They're **erased** before the program runs, so they can't check data that arrives from outside, like a network response or a model's JSON. Watch what happens when you simply *tell* TypeScript what the data is, using a cast:\n\n``` js\nconst trusted = JSON.parse('{\"role\": \"robot\", \"content\": 42}') as Message;\nconsole.log(trusted.role, typeof trusted.content);\n// robot number\n```\n\nTypeScript believed us. The \"message\" has a role that isn't allowed and content that isn't a string, and nothing complained. A cast (`as Message`) is a promise you make to the compiler, and it doesn't verify anything. So for data from outside your program, never cast. Validate.\n\nYou can write a type guard by hand, as above, but for anything bigger, use a library like **Zod**, the TypeScript equivalent of Pydantic. You describe the shape *once*, and you get both runtime validation and the TypeScript type:\n\n``` js\nimport { z } from \"zod\";\n\nconst MessageSchema = z.object({\n  role: z.enum([\"user\", \"assistant\", \"system\"]),\n  content: z.string(),\n  tokens: z.number().int().nonnegative().optional(),\n});\ntype ValidatedMessage = z.infer<typeof MessageSchema>;\n\nconst good: ValidatedMessage = MessageSchema.parse({ role: \"user\", content: \"hi\" });\nconsole.log(good);     // { role: 'user', content: 'hi' }\n\nconst result = MessageSchema.safeParse({ role: \"robot\", content: 42 });\nif (!result.success) {\n  for (const issue of result.error.issues) {\n    console.log(\" -\", issue.path.join(\".\"), issue.message);\n  }\n}\n// - role Invalid enum value. Expected 'user' | 'assistant' | 'system', received 'robot'\n// - content Expected string, received number\n```\n\n`parse` returns the clean, typed data, or throws a `ZodError`. `safeParse` returns a result object instead of throwing, with `success` telling you which it was, so you can handle failure without `try/catch`. This is exactly the \"validate the model's reply\" pattern from Post 10, and it's how you'd check structured output in a TypeScript app.\n\n(I ran this with Zod 3.23.8, installed with `npm install zod`. Zod's error-message wording changes between major versions, so yours may read differently, and the basic API is the same.)\n\n## Async, fetch, and the web\n\nIf you've done Post 14, JavaScript's async will feel like home. In fact, it's simpler, because JavaScript has had an event loop built in from the start. There's no `asyncio.run`. You just write `async` functions and `await` them, and in modern modules you can even `await` at the top level of a file.\n\nThe equivalent of `asyncio.gather` is `Promise.all`. I made three delayed values of different types:\n\n```\nasync function delay<T>(value: T, ms: number): Promise<T> {\n  await new Promise((resolve) => setTimeout(resolve, ms));\n  return value;\n}\n\nconst results = await Promise.all([delay(\"a\", 30), delay(2, 10), delay(true, 20)]);\nconsole.log(results);\n// [ 'a', 2, true ]\n```\n\nThey run concurrently, and the results come back in the original order, just like `gather`. TypeScript even tracks each element's type.\n\n### fetch, and its most important gotcha\n\nJavaScript has `fetch` built in (it's in browsers, and in Node 18 and newer). It's the equivalent of Python's `requests`:\n\n``` js\nconst response = await fetch(\"http://localhost:8000/ask\", {\n  method: \"POST\",\n  headers: { \"Content-Type\": \"application/json\" },\n  body: JSON.stringify({ question: \"What is HTTP?\" }),\n  signal: AbortSignal.timeout(5000),\n});\n```\n\n`AbortSignal.timeout(5000)` gives the request a five-second limit, the equivalent of `timeout=5` in Python, and just as essential.\n\nNow the gotcha, which catches everyone coming from `requests`: `fetch` **does not throw on HTTP errors.** A 404 or a 500 is still a successful *conversation*, so the promise resolves normally. Only network failures and timeouts throw. You must check `response.ok` yourself, which is the job `raise_for_status()` did in Python. I sent an empty request to the server from Post 20:\n\n```\nfetch resolved normally with status 422 ok = false { error: \"missing 'question'\" }\n```\n\nNo exception. If you forget the `response.ok` check, your code carries on treating an error message as a good answer.\n\n## Streaming in TypeScript\n\nStreaming is where TypeScript shines, because it's what powers chat screens. A `fetch` response has a `body`, which is a stream of bytes you can read piece by piece. Here's the plan, and it should remind you of Posts 7 and 20:\n\n1. Get a **reader** from`response.body` .\n2. Read chunks as they arrive, and decode the bytes into text.\n3. Collect the text in a **buffer** , and pull out complete lines.\n4. For each `data:` line, parse the JSON and**yield** the token.\n\nStep 3 deserves a warning. A chunk from the network can end *in the middle of a line*, or even in the middle of a word, so you can't just parse each chunk as it comes. You keep a buffer, and only process lines once you've seen their newline. We'll test exactly that in the mini project.\n\nThe natural way to expose this is an **async generator**, the TypeScript version of the generators from Post 7 and the `async for` from Post 14. You write `async function*`, and consume it with `for await`:\n\n``` js\nfor await (const token of streamTokens(\"Streaming makes replies feel faster\")) {\n  process.stdout.write(token);\n}\n```\n\n(In a browser, you'd append each token to a page element instead. The browser's built-in `EventSource` for server-sent events only makes `GET` requests and can't set headers, as we saw in Post 20, so web apps typically read the stream with `fetch`, using the same technique.)\n\n## The ecosystem, briefly\n\nI can't teach all of web development here, so here's a map of what you'll meet. This part is from my knowledge, and these tools change quickly, so check current documentation before choosing.\n\n- **React** is the most widely used library for building user interfaces, and**Next.js** is a popular framework built on it that also lets you write server code in the same project. Learning React basics is the natural next step after this post if you want to build chat screens.\n- **Servers** in Node are commonly built with frameworks like Express, Fastify, or Hono. For simple needs, Next.js's built-in server routes are enough.\n- **SDKs.** Model providers publish TypeScript SDKs, there are higher-level toolkits for building AI apps in TypeScript, and an official TypeScript SDK exists for the Model Context Protocol, which we'll meet in Post 56.\n- **Testing.** Vitest and Jest are popular. Node also has a built-in test runner (`node:test` ), which is what we'll use below.\n- **Formatting and linting.** Prettier formats code, and ESLint (or Biome) lints it, like the Python tools from Post 24.\n\nAnd a decision guide, since people often ask which language to use:\n\n| Choose | When | \n|---|---|\n| **Python** for the backend | The work is data, ML, evals, notebooks, or training, and you need Python's libraries | \n| **TypeScript** for the backend | The product is mostly web, you want one language across front and back end, or you're deploying to serverless or edge platforms | \n| **Both** | Very common: a Python service does the ML work, a TypeScript app builds the product, and they talk over HTTP | \n\n## Mini project: a typed client for the Post 20 API\n\nLet's put it all together. We'll write a fully typed TypeScript client for the server from Post 20: asking questions, streaming tokens, and retrying on rate limits, with real tests. The project needs the `package.json` scripts and the `tsconfig.json` from above, and then two files. Create `src/client.ts`:\n\n``` js\nconst BASE = process.env.API_BASE ?? \"http://localhost:8000\";\n\nexport class ApiError extends Error {\n  constructor(\n    public status: number,\n    message: string,\n  ) {\n    super(message);\n    this.name = \"ApiError\";\n  }\n}\n\ntype FetchFn = typeof fetch;\n\nfunction isAnswer(value: unknown): value is { answer: string } {\n  return (\n    typeof value === \"object\" &&\n    value !== null &&\n    typeof (value as Record<string, unknown>).answer === \"string\"\n  );\n}\n\nexport async function ask(question: string, fetchFn: FetchFn = fetch): Promise<string> {\n  const response = await fetchFn(`${BASE}/ask`, {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ question }),\n    signal: AbortSignal.timeout(5000),\n  });\n  if (!response.ok) {\n    throw new ApiError(response.status, `ask failed with status ${response.status}`);\n  }\n  const data: unknown = await response.json();\n  if (!isAnswer(data)) {\n    throw new Error(\"unexpected response shape from /ask\");\n  }\n  return data.answer;\n}\n\nexport async function* streamTokens(text: string, fetchFn: FetchFn = fetch): AsyncGenerator<string> {\n  const response = await fetchFn(`${BASE}/stream`, {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ text }),\n    signal: AbortSignal.timeout(10000),\n  });\n  if (!response.ok || response.body === null) {\n    throw new ApiError(response.status, `stream failed with status ${response.status}`);\n  }\n\n  const reader = response.body.getReader();\n  const decoder = new TextDecoder();\n  let buffer = \"\";\n\n  try {\n    while (true) {\n      const { done, value } = await reader.read();\n      if (done) break;\n      buffer += decoder.decode(value, { stream: true });\n\n      // a chunk can end in the middle of a line, so only handle complete lines\n      let newline: number;\n      while ((newline = buffer.indexOf(\"\\n\")) !== -1) {\n        const line = buffer.slice(0, newline).trimEnd();\n        buffer = buffer.slice(newline + 1);\n        if (!line.startsWith(\"data: \")) continue;\n\n        const payload = line.slice(\"data: \".length);\n        if (payload === \"[DONE]\") return;\n        const event: unknown = JSON.parse(payload);\n        if (typeof event === \"object\" && event !== null && \"token\" in event) {\n          yield String((event as { token: unknown }).token);\n        }\n      }\n    }\n  } finally {\n    reader.releaseLock();\n  }\n}\n\nconst sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));\n\nexport async function getWithRetries<T>(\n  path: string,\n  attempts = 4,\n  fetchFn: FetchFn = fetch,\n  sleepFn: (ms: number) => Promise<unknown> = sleep,\n): Promise<T> {\n  for (let attempt = 1; attempt <= attempts; attempt++) {\n    const response = await fetchFn(`${BASE}${path}`, { signal: AbortSignal.timeout(5000) });\n\n    if (response.status === 429 || response.status >= 500) {\n      const retryAfter = Number(response.headers.get(\"Retry-After\"));\n      const waitSeconds = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : 0.1 * 2 ** attempt;\n      console.log(`Attempt ${attempt}: got ${response.status}, waiting ${waitSeconds.toFixed(1)} s`);\n      await sleepFn(waitSeconds * 1000);\n      continue;\n    }\n    if (!response.ok) {\n      throw new ApiError(response.status, `${path} failed with status ${response.status}`);\n    }\n    return (await response.json()) as T;\n  }\n  throw new Error(`${path} still failing after ${attempts} attempts`);\n}\n\nasync function main() {\n  console.log(await ask(\"What is HTTP?\"));\n\n  const start = performance.now();\n  let firstTokenAt: number | null = null;\n  for await (const token of streamTokens(\"Streaming makes replies feel faster\")) {\n    firstTokenAt ??= (performance.now() - start) / 1000;\n    process.stdout.write(token);\n  }\n  const total = (performance.now() - start) / 1000;\n  console.log(`\\nfirst token after ${firstTokenAt?.toFixed(2)} s, finished after ${total.toFixed(2)} s`);\n\n  console.log(await getWithRetries<{ status: string; calls: number }>(\"/flaky\"));\n\n  try {\n    await fetch(`${BASE}/ask`, {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: \"{}\",\n    }).then(async (r) => {\n      console.log(\"fetch resolved normally with status\", r.status, \"ok =\", r.ok, await r.json());\n    });\n    await ask(\"\");\n  } catch (error) {\n    if (error instanceof ApiError) {\n      console.log(`ApiError: status ${error.status}`);\n    } else {\n      throw error;\n    }\n  }\n}\n\nif (import.meta.url === `file://${process.argv[1]}`) {\n  await main();\n}\n```\n\nTake a moment with the design. Several ideas from earlier posts are hiding in there:\n\n- **Dependency injection** (Post 24). Each function takes a`fetchFn` parameter that defaults to the real`fetch` . In production you ignore it. In tests you pass a fake. That one default parameter is what makes everything testable without a server.`getWithRetries` also takes a`sleepFn` , so tests don't have to wait through real delays.\n- **Validation at the edge.**`ask` doesn't trust the response. It reads it as`unknown` , and a type guard checks the shape before returning`data.answer` .\n- **A typed error.**`ApiError` carries the status code, so callers can react to a 422 differently from a 500. This is the custom-exception pattern from Post 11.\n- **A discriminated retry rule.** 429 and 5xx are retried, honoring`Retry-After` . Other errors, like a 404, are not (Post 20).\n- **Cleanup.** A`finally` block releases the stream reader even if something goes wrong.\n- **The** `main` **guard** at the bottom runs the demo only when you run the file directly, the TypeScript counterpart of`if __name__ == \"__main__\":` from Post 12.\n\nNow the tests, using Node's built-in test runner, so there's nothing extra to install. Create `src/client.test.ts`:\n\n``` python\nimport assert from \"node:assert/strict\";\nimport test from \"node:test\";\n\nimport { ApiError, ask, getWithRetries, streamTokens } from \"./client.js\";\n\nfunction jsonResponse(status: number, body: unknown, headers: Record<string, string> = {}): Response {\n  return new Response(JSON.stringify(body), {\n    status,\n    headers: { \"Content-Type\": \"application/json\", ...headers },\n  });\n}\n\n// a fake fetch that returns queued responses and remembers how often it was called\nfunction fakeFetch(...responses: Response[]) {\n  const queue = [...responses];\n  const calls: string[] = [];\n  const fn = (async (input: string | URL | Request) => {\n    calls.push(String(input));\n    const next = queue.shift();\n    if (!next) throw new Error(\"fake fetch ran out of responses\");\n    return next;\n  }) as typeof fetch;\n  return { fn, calls };\n}\n\nconst noSleep = async () => {};\n\ntest(\"getWithRetries retries on 429 and then succeeds\", async () => {\n  const { fn, calls } = fakeFetch(\n    jsonResponse(429, { error: \"slow down\" }, { \"Retry-After\": \"1\" }),\n    jsonResponse(429, { error: \"slow down\" }, { \"Retry-After\": \"1\" }),\n    jsonResponse(200, { status: \"finally worked\" }),\n  );\n  const result = await getWithRetries<{ status: string }>(\"/flaky\", 4, fn, noSleep);\n  assert.equal(result.status, \"finally worked\");\n  assert.equal(calls.length, 3);\n});\n\ntest(\"getWithRetries does not retry a 404\", async () => {\n  const { fn, calls } = fakeFetch(jsonResponse(404, { error: \"nope\" }));\n  await assert.rejects(\n    () => getWithRetries(\"/missing\", 4, fn, noSleep),\n    (error: unknown) => error instanceof ApiError && error.status === 404,\n  );\n  assert.equal(calls.length, 1);\n});\n\ntest(\"getWithRetries gives up after the last attempt\", async () => {\n  const { fn, calls } = fakeFetch(jsonResponse(503, {}), jsonResponse(503, {}));\n  await assert.rejects(() => getWithRetries(\"/down\", 2, fn, noSleep), /still failing after 2 attempts/);\n  assert.equal(calls.length, 2);\n});\n\ntest(\"ask returns the answer\", async () => {\n  const { fn } = fakeFetch(jsonResponse(200, { answer: \"42\" }));\n  assert.equal(await ask(\"What is 6 x 7?\", fn), \"42\");\n});\n\ntest(\"ask turns an HTTP error into an ApiError\", async () => {\n  const { fn } = fakeFetch(jsonResponse(422, { error: \"missing 'question'\" }));\n  await assert.rejects(\n    () => ask(\"\", fn),\n    (error: unknown) => error instanceof ApiError && error.status === 422,\n  );\n});\n\ntest(\"ask rejects a response with the wrong shape\", async () => {\n  const { fn } = fakeFetch(jsonResponse(200, { unexpected: true }));\n  await assert.rejects(() => ask(\"hi\", fn), /unexpected response shape/);\n});\n\ntest(\"streamTokens handles chunks that split a line in half\", async () => {\n  const pieces = ['data: {\"tok', 'en\": \"Hel\"}\\n\\ndata: {\"token\": \"lo\"}\\n\\nda', \"ta: [DONE]\\n\\n\"];\n  const encoder = new TextEncoder();\n  const body = new ReadableStream<Uint8Array>({\n    start(controller) {\n      for (const piece of pieces) controller.enqueue(encoder.encode(piece));\n      controller.close();\n    },\n  });\n  const { fn } = fakeFetch(new Response(body, { status: 200 }));\n\n  const tokens: string[] = [];\n  for await (const token of streamTokens(\"ignored\", fn)) tokens.push(token);\n  assert.deepEqual(tokens, [\"Hel\", \"lo\"]);\n});\n```\n\n(The `./client.js` import path looks odd, because the file is `client.ts`. That's the convention in modern TypeScript: you import the name the compiled JavaScript will have, and tools like `tsx` and `tsc` understand the mapping.)\n\nThe last test is the interesting one. It cuts the stream in awkward places, even in the middle of the word `data`, and checks that the parser still reassembles `[\"Hel\", \"lo\"]`, with no real network involved.\n\n### Running it\n\nFirst, start the Python server from Post 20 (`python server.py`) in one terminal. Then, in the TypeScript project:\n\n```\nnpm run check\nnpm test\nnpm start\n```\n\n`npm run check` should print nothing but the command header, meaning **zero type errors under strict mode**. `npm test` should end with:\n\n```\n# tests 7\n# pass 7\n# fail 0\n```\n\nAnd `npm start` runs the client against the live server:\n\n```\nYou asked: What is HTTP?\nStreaming makes replies feel faster \nfirst token after 0.01 s, finished after 1.01 s\nAttempt 1: got 429, waiting 1.0 s\nAttempt 2: got 429, waiting 1.0 s\n{ status: 'finally worked', calls: 3 }\nfetch resolved normally with status 422 ok = false { error: \"missing 'question'\" }\nApiError: status 422\n```\n\nThat matches the Python client's output in Post 20: the same fast first token, the same `Retry-After` retries, and the same 422 handling, from a different language. (The flaky endpoint only fails on its first two calls, so restart the server before running again.)\n\nFinally, a sanity check on the tests themselves. I deliberately broke the retry logic, stopping it from retrying on 429, and ran the tests. One failed, naming the exact behavior I'd broken, and after I restored the code, all seven passed again. Tests that can fail are tests worth having.\n\n### Make it yours\n\n1. Replace the hand-written `isAnswer` guard with a Zod schema (`npm install zod` ), and compare the two.\n2. Add jitter (a little randomness) to the retry wait, and update the tests to keep them deterministic.\n3. Add a `listCourses()` function with a proper`Course` type, plus validation.\n4. Make `streamTokens` support cancellation with an`AbortSignal` that the caller can trigger.\n5. Write a small HTML page with a text box that calls the stream and appends tokens to a `<div>` . You'll hit CORS (Post 20), so serve the page from the same server, or add the right headers.\n6. Port the retry helper to also accept an `isRetryable` function, the strategy pattern from Post 24.\n\n## Practice\n\nAbout 20 minutes of real effort on each before asking for help.\n\n1. Translate the word counter from Post 5, `chunk_words` from Post 17, and the`retry` helper from Post 6 into TypeScript. Type-check each with`strict` on.\n2. Write the types and a type guard for a model request and response, then write the same thing with Zod, and compare how much code each takes.\n3. Trigger five of the compiler errors listed in this post on purpose, then fix them.\n4. Port the `batches` generator from Post 7 into a TypeScript async generator, and consume it with`for await` .\n5. Write `mapWithLimit(items, limit, fn)` , which runs async work with at most`limit` tasks at a time, the TypeScript equivalent of the semaphore from Post 14.\n6. Make a file containing every JavaScript gotcha from this post ( `==` ,`[]` truthiness,`.sort()` ,`||` versus`??` ), run`tsc` on it, and see which ones TypeScript catches for you.\n7. Add a test for the client where the server sends a malformed JSON event in the stream. Decide what the right behavior is, and implement it.\n8. Explain in your own words why `as Message` is dangerous, and what you'd use instead.\n\nThen ask an AI tutor to review your answers, and don't let it rewrite them.\n\n## What to take away\n\n- TypeScript is JavaScript plus types. The types are erased, so they catch mistakes at development time and do nothing at runtime.\n- `tsx` runs your code without checking it, and`tsc --noEmit` checks without running. Use`strict: true` , and run the check often.\n- The JavaScript gotchas to remember: use `===` , know that`[]` is truthy, use`??` instead of`||` for defaults, pass a comparator to`.sort()` , and watch out for big numbers.\n- Interfaces, literal unions, optional fields, and discriminated unions let the compiler catch typos, missing fields, and unhandled cases.\n- Use `unknown` instead of`any` , and never cast data from outside. Validate it with a type guard or Zod.\n- `async` /`await` and`Promise.all` work like their Python counterparts.`fetch` doesn't throw on HTTP errors, so always check`response.ok` , and give every request a timeout.\n- Streaming is a reader plus a buffer plus an async generator. Chunks can split lines, so buffer them.\n- Injecting `fetch` as a parameter makes everything testable with fakes.\n- Python and TypeScript work well together, with HTTP as the bridge.\n\n**Previous:** Post 24, *Clean Code & Design Patterns*\n\n**Next in the series:** Post 26, *Intro to C++/Rust*, which is optional. It covers only what you need to read the performance-critical code that sits underneath AI libraries.", "url": "https://wpnews.pro/news/typescript-for-ai-engineers-the-language-of-ai-web-apps", "canonical_source": "https://zyvop.com/typescript-for-ai-engineers-the-language-of-ai-web-apps-2ge48", "published_at": "2026-11-06 07:37:31+00:00", "updated_at": "2026-11-06 07:49:47.569307+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "artificial-intelligence"], "entities": ["TypeScript", "Node.js", "npm", "tsx", "React", "Next.js", "ESLint"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/typescript-for-ai-engineers-the-language-of-ai-web-apps", "markdown": "https://wpnews.pro/news/typescript-for-ai-engineers-the-language-of-ai-web-apps.md", "text": "https://wpnews.pro/news/typescript-for-ai-engineers-the-language-of-ai-web-apps.txt", "jsonld": "https://wpnews.pro/news/typescript-for-ai-engineers-the-language-of-ai-web-apps.jsonld"}}