TypeScript in Practice

Table of Contents

  1. Plan the Conversion
  2. Typing the Project Data
  3. Narrowing
  4. Discriminated Unions
  5. Typing DOM Queries
  6. Typing fetch and JSON
  7. Why a Type Is Not Validation
  8. Build to dist/ and Load It
  9. Linting and Formatting
  10. Where to Go Next
  11. Series Wrap-Up
  12. 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:

  1. Add TypeScript and a tsconfig.json (from TypeScript Basics).
  2. Move the JavaScript into a src/ folder and rename files from .js to .ts.
  3. Fix the errors the compiler reports, one file at a time.
  4. Compile to dist/ and point index.html at 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 type brings 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 .js files, 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:

CheckExampleNarrows
typeoftypeof x === "string"Primitive types
Truthinessif (url) { ... }Removes null, undefined, "", 0
Equalityx === nullRemoves that value
in"meow" in petPicks the object type that has the property
instanceoferror instanceof ValidationErrorClass 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 dataTrust the type?
Your own code and constantsYes, the compiler checks them
Network responses, JSON.parse, localStorageNo, check at runtime
User input from formsNo, 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:

GoalNext step
Build large interactive interfacesReact with TypeScript. The component and data-typing skills in this chapter apply directly.
Build servers and APIsNode.js with a framework. Try the NestJS Crash Course, which is written in TypeScript.
Store and query dataSQL and databases such as PostgreSQL
Keep learning in orderThe 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:

ChaptersYou learned
1-3What JavaScript and TypeScript are; values, variables, types, operators, and conditions
4-6Loops, functions, scope, arrays, and objects
7Modules, Node, and npm
8-9The DOM, events, and asynchronous code with fetch
10Errors, try/catch, and debugging
11-12TypeScript, 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

  1. Convert the filter. Move your project filter into src/, rename it to .ts, and fix every compiler error. Which error was the most useful?
  2. Add a field. Add a featured: boolean to the Project type. What does the compiler force you to change? Fix each place.
  3. Narrow a union. Write formatDate(value: string | number | Date) that returns a readable string for each type, using typeof and instanceof.
  4. Model the repo list. Use the LoadState discriminated union for the GitHub list. Render "Loading...", the repo names, or the error message depending on state.status.
  5. Break the type on purpose. Point loadRepos at a URL that returns a different shape. What does the page do, and why did the compiler not warn you? Then use isRepo to protect it.
  6. Build and ship. Run npm run build, load dist/main.js as a module in index.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!