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.
This 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.
Everything 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.
Why TypeScript, and what it actually is
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.
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.
Where does it fit in an AI app? Here's the usual shape:
browser (TypeScript/React) --> your server (TypeScript or Python) --> model API the chat screen holds the API key, adds logic the model The 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.
Setting up
You need Node.js (get the LTS version from nodejs.org). Check it:
node --version npm --version npm is Node's package manager. It does for JavaScript what pip and uv do for Python. Create a project:
mkdir chat-api-client cd chat-api-client npm init -y npm install --save-dev typescript tsx @types/node That installs three development tools:
typescriptis the compiler and type checker.tsxruns TypeScript files directly, without a separate build step. It's the easiest way to run TS while learning.@types/nodeteaches TypeScript about Node's built-in features.
Notice 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.
Add "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:
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "noEmit": true, "skipLibCheck": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["node"] }, "include": ["src"] } The 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.
Now you have two commands, and the difference between them is important:
npx tsx src/hello.ts # RUN the file. Does NOT check types. npx tsc --noEmit # CHECK the types. Doesn't run anything. 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:
"scripts": { "start": "tsx src/client.ts", "check": "tsc --noEmit", "test": "tsx --test src/*.test.ts" } Then 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.)
JavaScript for Python programmers
TypeScript is JavaScript, so you need the JavaScript basics first. Here's a translation table, and then the gotchas that bite Python programmers.
| Python | TypeScript / JavaScript |
|---|---|
| x = 5 | const x = 5 (or let x = 5 if it will change) |
| None | null or undefined (two kinds of "nothing") |
| True / False | true / false |
| f"Hello, {name}" | Hello, ${name} (backticks) |
| len(items) | items.length |
| x in items | items.includes(x) |
| for x in items: | for (const x of items) { ... } |
| [n * n for n in nums if n % 2 == 0] | nums.filter(n => n % 2 === 0).map(n => n * n) |
| {"a": 1} (dict) | { a: 1 } (an object), or a Map |
| print(x) | console.log(x) |
| def f(x): ... | function f(x) { ... } or const f = (x) => ... |
| try / except | try / catch |
| async def / await | async function / await |
| dataclass / Pydantic | interface or type, plus Zod for runtime checks |
| pip / uv, pyproject.toml | npm, package.json |
| pytest | Vitest, Jest, or Node's built-in test runner |
I ran each of the following. Here they are, with the output.
One number type. There's no separate int and float:
console.log(typeof 1, typeof 1.5, 0.1 + 0.2, 7 / 2, Math.floor(7 / 2)); // number number 0.30000000000000004 3.5 3 Note 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.
Two kinds of "nothing". null means "deliberately empty," and undefined means "never set." You'll see both:
let a: string | null = null; let b: string | undefined; console.log(a, b, a == b, a === b); // null undefined true false Always use ===. The double-equals == converts types before comparing, with surprising results:
console.log(0 == "", 0 === "", "1" == 1); // true false true 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.
Truthiness differs from Python. An empty array and an empty object are truthy in JavaScript, while they're falsy in Python:
console.log([] ? "empty array is truthy" : "falsy"); // empty array is truthy console.log([0, "", null, undefined, NaN].map((v) => Boolean(v))); // [ false, false, false, false, false ] The 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.
?? 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:
const retries = 0; console.log("with ||:", retries || 3, "| with ??:", retries ?? 3); // with ||: 3 | with ??: 0 If 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:
const user: { profile?: { email?: string } } = {}; console.log(user.profile?.email, user.profile?.email ?? "no email"); // undefined no email Without ?., user.profile.email would crash, as None.email does in Python.
Array methods replace comprehensions:
const nums = [1, 2, 3, 4, 5, 6]; console.log(nums.filter((n) => n % 2 === 0).map((n) => n * n)); // [ 4, 16, 36 ] console.log(nums.reduce((sum, n) => sum + n, 0)); // 21 console.log(nums.find((n) => n > 3), nums.some((n) => n > 5), nums.every((n) => n > 0)); // 4 true true The sorting gotcha. .sort() with no argument sorts as text:
console.log([10, 9, 1, 100].sort()); // [ 1, 10, 100, 9 ] console.log([10, 9, 1, 100].sort((x, y) => x - y)); // [ 1, 9, 10, 100 ] Always pass a comparison function for numbers.
Destructuring and spread are lovely, and you'll use them constantly:
const { role, content } = { role: "user", content: "hi", extra: 1 }; const [first, ...rest] = [1, 2, 3]; const merged = { ...{ model: "x", temperature: 0.7 }, temperature: 0.2 }; console.log(role, content, first, rest, merged); // user hi 1 [ 2, 3 ] { model: 'x', temperature: 0.2 } The 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.
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:
const counts: Record<string, number> = {}; for (const word of "the cat the dog".split(" ")) { counts[word] = (counts[word] ?? 0) + 1; } console.log(counts); // { the: 2, cat: 1, dog: 1 } That's the word counter from Post 5 again, with ?? playing the role of .get(word, 0).
JSON is built in, and speaks the language natively:
const text = JSON.stringify({ role: "user", content: "hi" }); console.log(text, JSON.parse(text).content); // {"role":"user","content":"hi"} hi JSON.parse throws a SyntaxError on bad input, so wrap it in try/catch when the data comes from outside.
Big numbers lose precision. All numbers are 64-bit floats, so whole numbers are only exact up to a point:
console.log(Number.MAX_SAFE_INTEGER, 9007199254740993, 9007199254740993n); // 9007199254740991 9007199254740992 9007199254740993n The 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.
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:
for (const x of ["a", "b"]) console.log("of:", x); // of: a, of: b for (const i in ["a", "b"]) console.log("in:", i, typeof i); // in: 0 string, in: 1 string TypeScript basics
Now the types. If you've done Post 10, much of this will feel familiar.
Describing shapes
An 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:
type Role = "user" | "assistant" | "system"; interface Message { role: Role; content: string; tokens?: number; // the ? makes it optional } function tokenCount(message: Message): number { return message.tokens ?? Math.ceil(message.content.length / 4); } console.log(tokenCount({ role: "user", content: "How do generators work?" })); // 6 console.log(tokenCount({ role: "user", content: "hi", tokens: 9 })); // 9 You 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.
Letting the compiler catch your mistakes
Here'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:
Type '"robot"' is not assignable to type '"user" | "assistant" | "system"'. You gave a role that isn't allowed.
Property 'content' is missing in type '{ role: "user"; }' but required in type 'Message'. A required field is missing.
'm.tokens' is possibly 'undefined'. You 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.
'text' is possibly 'null'. Calling text.toUpperCase() when text might be null.
Object literal may only specify known properties, but 'tokns' does not exist in type 'Message'. Did you mean to write 'tokens'? A typo in a field name, and TypeScript even suggests the fix.
Argument of type 'string' is not assignable to parameter of type 'number'. Calling double("21") on a function that takes a number.
'value' is of type 'unknown'. Using a value of type unknown without checking it first (more on that below).
Function lacks ending return statement and return type does not include 'undefined'. This one appeared in a switch that forgot a case, which brings us to the next feature.
Read each message from the start: it names the problem, and the position tells you the line. Most are short and clear.
Unions that tell you what you're holding
A 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:
type StreamEvent = | { type: "token"; token: string } | { type: "error"; message: string } | { type: "done" }; function describe(event: StreamEvent): string { switch (event.type) { case "token": return `token: ${event.token}`; case "error": return `error: ${event.message}`; case "done": return "done"; } } Inside 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.
unknown, not any
TypeScript 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":
function isMessage(value: unknown): value is Message { if (typeof value !== "object" || value === null) return false; const v = value as Record<string, unknown>; return ( (v.role === "user" || v.role === "assistant" || v.role === "system") && typeof v.content === "string" ); } const incoming: unknown = JSON.parse('{"role": "assistant", "content": "Hello"}'); if (isMessage(incoming)) { console.log("valid message:", incoming.content.toUpperCase()); // valid message: HELLO } console.log(isMessage({ role: "robot", content: "x" }), isMessage(null), isMessage("text")); // false false false The 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.
Generics and handy utilities
Generics let a function work with any type while staying type-safe, like list[T] in Python's hints:
function first<T>(items: T[]): T | undefined { return items[0]; } const n = first([1, 2, 3]); // TypeScript knows: number | undefined const s = first(["a", "b"]); // string | undefined You'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:
const ROLES = ["user", "assistant", "system"] as const; // a fixed list type RoleFromList = (typeof ROLES)[number]; // "user" | "assistant" | "system" type Options = { temperature: number; maxTokens: number; stream: boolean }; const overrides: Partial<Options> = { temperature: 0.2 }; // every field optional 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."
Types vanish at runtime
Here'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:
const trusted = JSON.parse('{"role": "robot", "content": 42}') Source:https://dev.to/pradeep_kumar_bc4e7e9f7ec/typescript-for-ai-engineers-the-language-of-ai-web-apps-45b4Related posts
