TypeScript in Practice
Table of Contents
- Plan the Conversion
- Typing the Project Data
- Narrowing
- Discriminated Unions
- Typing DOM Queries
- Typing fetch and JSON
- Why a Type Is Not Validation
- Build to dist/ and Load It
- Linting and Formatting
- Where to Go Next
- Series Wrap-Up
- Try It Yourself
1. Plan the Conversion
Dara's page now has a filterable project list, a theme toggle, a contact form validator, and a GitHub repos list. All of it is JavaScript. You do not need to rewrite everything at once. A safe order is:
- Add TypeScript and a
tsconfig.json(from TypeScript Basics). - Move the JavaScript into a
src/folder and rename files from.jsto.ts. - Fix the errors the compiler reports, one file at a time.
- Compile to
dist/and pointindex.htmlat the output.
The folder will look like this:
dara-profile/
├── index.html
├── styles.css
├── tsconfig.json
├── package.json
├── src/
│ ├── types.ts
│ ├── projects.ts
│ └── main.ts
└── dist/ (created by tsc)
Use the tsconfig.json from the previous chapter: strict on, rootDir
src, outDir dist, module ES2022.
2. Typing the Project Data
Start with the data, because every other file depends on it. Put the
shapes in src/types.ts:
export type Tag = "html" | "css" | "javascript";
export type Project = {
title: string;
year: number;
tag: Tag;
url?: string;
};
export type Repo = {
name: string;
html_url: string;
stargazers_count: number;
description: string | null;
};
Then use them in src/projects.ts:
import type { Project, Tag } from "./types.js";
export const projects: Project[] = [
{ title: "Profile Page", year: 2025, tag: "html" },
{ title: "Style Guide", year: 2025, tag: "css", url: "https://example.com" },
{ title: "Quiz App", year: 2026, tag: "javascript" },
];
export function filterProjects(list: Project[], tag: Tag | "all"): Project[] {
if (tag === "all") {
return list;
}
return list.filter((project) => project.tag === tag);
}
Two details to notice:
import typebrings in a type only. It disappears from the compiled output.- The import path ends in
.js, even though the file is.ts. The browser will load the compiled.jsfiles, and TypeScript does not rewrite paths for you.
Now a typo such as tag: "javscript" is a compile error, and
filterProjects(projects, "js") is too.
3. Narrowing
A union type like string | number | null allows several possibilities,
but each operation needs one specific type. Narrowing means checking a
value so TypeScript knows which case you are in. Inside the check, the
type becomes more precise.
function show(value: string | number | null): string {
if (value === null) {
return "none";
}
if (typeof value === "string") {
return value.toUpperCase(); // value is a string here
}
return value.toFixed(1); // only number is left
}
The common ways to narrow:
| Check | Example | Narrows |
|---|---|---|
typeof | typeof x === "string" | Primitive types |
| Truthiness | if (url) { ... } | Removes null, undefined, "", 0 |
| Equality | x === null | Removes that value |
in | "meow" in pet | Picks the object type that has the property |
instanceof | error instanceof ValidationError | Class instances |
The in check chooses between object types:
type Cat = { meow: () => void };
type Dog = { bark: () => void };
function speak(pet: Cat | Dog): void {
if ("meow" in pet) {
pet.meow(); // pet is Cat
} else {
pet.bark(); // pet is Dog
}
}
Truthiness is handy for optional values, with one warning: 0 and ""
are also falsy, so for numbers and text, prefer an explicit check such as
value !== undefined.
function label(url?: string): string {
if (url) {
return url.toLowerCase(); // url is string here
}
return "no link";
}
You can write your own narrowing function with a type predicate. The
return type value is Tag tells TypeScript what a true result means:
export function isTag(value: string): value is Tag {
return value === "html" || value === "css" || value === "javascript";
}
4. Discriminated Unions
When you have a few states that each carry different data, give every
state a shared literal property, usually called status or kind.
This is a discriminated union. Dara's repos list has three states:
type LoadState =
| { status: "loading" }
| { status: "success"; repos: string[] }
| { status: "error"; message: string };
function describe(state: LoadState): string {
switch (state.status) {
case "loading":
return "Loading...";
case "success":
return `${state.repos.length} repos`; // repos exists here
case "error":
return `Error: ${state.message}`; // message exists here
}
}
In each case, TypeScript knows exactly which shape you have, so
state.repos is allowed only in "success". If you add a fourth state
later and forget to handle it, the compiler can warn you, because the
function would no longer return a string on every path. This pattern
replaces many loose isLoading and error variables with one value that
cannot be in an impossible combination.
5. Typing DOM Queries
The DOM, from The DOM and Events,
is where TypeScript helps and annoys beginners most. querySelector might
find nothing, so it returns Element | null:
const input = document.querySelector("#name");
input.value; // Error: 'input' is possibly 'null'.
// Also: Property 'value' does not exist on type 'Element'.
There are two problems: it may be null, and a general Element has no
value. Fix both. Tell TypeScript the kind of element with a generic, then
handle null:
const nameInput = document.querySelector<HTMLInputElement>("#name");
if (nameInput) {
console.log(nameInput.value.trim()); // HTMLInputElement
}
For an element that must exist, fail loudly, which is also better than a silent bug:
const list = document.querySelector<HTMLUListElement>("#project-list");
if (!list) {
throw new Error("Missing #project-list in the HTML.");
}
// from here on, list is HTMLUListElement, not null
Common element types: HTMLInputElement, HTMLButtonElement,
HTMLFormElement, HTMLUListElement. With querySelectorAll, you get a
list that you loop over:
const filterButtons = document.querySelectorAll<HTMLButtonElement>("[data-tag]");
filterButtons.forEach((button) => {
button.addEventListener("click", () => {
const value = button.dataset.tag ?? "all";
if (value === "all" || isTag(value)) {
render(value); // value is Tag | "all" now
}
});
});
Here dataset.tag is a string | undefined, so we supply a default and
then use isTag to narrow a plain string into a Tag. Optional chaining
works as usual for elements that might be missing:
document.querySelector("#theme-toggle")?.addEventListener("click", () => {
document.documentElement.classList.toggle("dark");
});
Avoid the non-null assertion ! (as in document.querySelector("#x")!)
and the type cast as HTMLInputElement as habits. Both tell TypeScript to
trust you, and if you are wrong, you get the same crash as in JavaScript.
6. Typing fetch and JSON
await response.json() has the type any, because TypeScript cannot see
into a network response. A beginner's first idea is to describe the data
and trust it:
async function loadRepos(user: string): Promise<Repo[]> {
const response = await fetch(`https://api.github.com/users/${user}/repos`);
if (!response.ok) {
throw new Error(`GitHub returned ${response.status}`);
}
const data: unknown = await response.json();
if (!Array.isArray(data)) {
throw new Error("Unexpected response from GitHub.");
}
return data as Repo[];
}
Notice the pieces: the return type Promise<Repo[]> is a generic you now
recognize. We store the JSON as unknown, not any, so TypeScript forces
a check. We check response.ok (from
Asynchronous JavaScript)
and confirm we received an array before using it.
The last line, data as Repo[], is a type assertion: you promise
TypeScript the data has that shape. It is the weakest link, and the next
section explains why.
7. Why a Type Is Not Validation
This is the most important idea in TypeScript:
Types are erased when the code is compiled. The JavaScript that runs in the browser contains no types at all.
Look at what tsc produces for the line above. The browser receives
plain JavaScript that just returns data. If GitHub changes the response
tomorrow and html_url disappears, nothing in the running page will
notice. Your Repo type says the field exists; the real data says
otherwise; the page shows undefined or crashes.
So:
| Source of data | Trust the type? |
|---|---|
| Your own code and constants | Yes, the compiler checks them |
Network responses, JSON.parse, localStorage | No, check at runtime |
| User input from forms | No, validate (as in the contact form) |
To make the data safe, check the shape at runtime and let that check narrow the type. A type predicate does both jobs:
function isRepo(value: unknown): value is Repo {
return (
typeof value === "object" &&
value !== null &&
"name" in value &&
typeof value.name === "string" &&
"html_url" in value &&
typeof value.html_url === "string"
);
}
Then data.filter(isRepo) keeps only the items that really match. For
larger projects, people use validation libraries such as Zod, which build
both the runtime check and the type from one description. The principle
stays the same: types protect the code you write; runtime checks protect
you from the outside world.
8. Build to dist/ and Load It
Compile the whole project:
npx tsc
This reads tsconfig.json and writes dist/main.js, dist/projects.js,
and dist/types.js. Add a script to package.json so the command is easy
to remember:
{
"scripts": {
"build": "tsc",
"watch": "tsc --watch"
}
}
Now npm run build compiles once, and npm run watch recompiles every
time you save. In index.html, load the compiled file as a module, so
that its import lines work:
<script type="module" src="dist/main.js"></script>
Open the page with Live Server (module scripts do not load from a plain
file:// address in most browsers). If something is wrong, DevTools still
works: check the Console and the Network tab. Remember that the browser
runs dist/*.js, so errors point at the compiled files.
Commit your src/ and tsconfig.json. Whether to commit dist/ depends
on how you publish: GitHub Pages serves files from the repository, so for
Dara's site it should be committed. Larger projects usually ignore dist/
and build during deployment.
9. Linting and Formatting
Two small tools make code easier to keep tidy:
- Prettier is a formatter. It rewrites your code with consistent spacing, quotes, and line breaks, so you never discuss style.
- ESLint is a linter. It finds likely mistakes that are still
valid code, such as unused variables, and has a TypeScript plugin
(
typescript-eslint).
npm i -D prettier eslint
npx prettier --write src
Setting ESLint up takes a few steps, so follow its current guide at eslint.org. VS Code can also run Prettier whenever you save a file. The compiler catches type errors, ESLint catches suspicious habits, and Prettier handles the layout. Together they catch many problems before you open the browser.
10. Where to Go Next
You have the foundations. Pick the direction that matches what you want to build:
| Goal | Next step |
|---|---|
| Build large interactive interfaces | React with TypeScript. The component and data-typing skills in this chapter apply directly. |
| Build servers and APIs | Node.js with a framework. Try the NestJS Crash Course, which is written in TypeScript. |
| Store and query data | SQL and databases such as PostgreSQL |
| Keep learning in order | The Start Here roadmap lists each track |
Whatever you choose, keep a small project going. Reading helps, but the real learning happens when you build something and fix what breaks.
11. Series Wrap-Up
You started with console.log("Hello") and ended with a typed program.
Along the way:
| Chapters | You learned |
|---|---|
| 1-3 | What JavaScript and TypeScript are; values, variables, types, operators, and conditions |
| 4-6 | Loops, functions, scope, arrays, and objects |
| 7 | Modules, Node, and npm |
| 8-9 | The DOM, events, and asynchronous code with fetch |
| 10 | Errors, try/catch, and debugging |
| 11-12 | TypeScript, narrowing, and typing real browser code |
Dara's page can now filter projects, switch themes, validate a contact form, and load GitHub repositories, with the logic checked by the compiler. You can read an error message without panic, find a bug with a breakpoint, and tell the difference between what a type promises and what the program really receives.
12. Try It Yourself
- Convert the filter. Move your project filter into
src/, rename it to.ts, and fix every compiler error. Which error was the most useful? - Add a field. Add a
featured: booleanto theProjecttype. What does the compiler force you to change? Fix each place. - Narrow a union. Write
formatDate(value: string | number | Date)that returns a readable string for each type, usingtypeofandinstanceof. - Model the repo list. Use the
LoadStatediscriminated union for the GitHub list. Render "Loading...", the repo names, or the error message depending onstate.status. - Break the type on purpose. Point
loadReposat a URL that returns a different shape. What does the page do, and why did the compiler not warn you? Then useisRepoto protect it. - Build and ship. Run
npm run build, loaddist/main.jsas a module inindex.html, and test every feature. Push it to GitHub Pages as you did at the end of the HTML & CSS series.
You can now write JavaScript, add TypeScript to it, and debug both. Keep building small, real things, and come back to this series whenever you need a reminder.
Buy Me a Coffee
Found this tutorial helpful? Consider buying me a coffee to support more free content!