Worked Example: Migrating a Module to TypeScript

This section documents the current TypeScript release line as published at the official TypeScript documentation, which is the reference these pages are written and verified against. No specific patch version is pinned.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, since TypeScript iterates quickly.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page takes one small module — a fetch wrapper that tracks loading, success and error state — and moves it to TypeScript one step at a time, following the handbook’s Migrating from JavaScript guide. Each step is small enough to paste into the TypeScript Playground and watch the checker react. Every step also links back to the reference page that explains it in depth.

Step 1 — The plain JavaScript module

The starting point: no build step, no types, onState is called with an ad-hoc object.

// src/http-client.js
export function createClient(baseUrl) {
  return {
    async get(path) {
      const res = await fetch(baseUrl + path);
      if (!res.ok) throw new Error("HTTP " + res.status);
      return res.json();
    },
  };
}

export async function load(client, path, onState) {
  onState({ status: "loading" });
  try {
    const data = await client.get(path);
    onState({ status: "success", data });
  } catch (err) {
    onState({ status: "error", error: err.message });
  }
}

Step 2 — Add a tsconfig.json with allowJs

Install the compiler and add a config that accepts .js files, emits nothing yet, and only type-checks files you opt in. See tsconfig and Compiler Options and the allowJs reference.

{
  "compilerOptions": {
    "target": "es2020",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "allowJs": true,
    "checkJs": false,
    "noEmit": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}
npm install --save-dev typescript
npx tsc --noEmit          # builds the program, reports nothing yet

Step 3 — Add // @ts-check and JSDoc types, no rename

Opt this one file in with a top comment and describe its shapes in JSDoc. Nothing is renamed. See JavaScript Interop and the handbook’s JSDoc Reference.

// @ts-check
// src/http-client.js

/** @typedef {{ status: "loading" }
 *          | { status: "success", data: unknown }
 *          | { status: "error", error: string }} State */

/** @param {string} baseUrl */
export function createClient(baseUrl) {
  return {
    /** @param {string} path @returns {Promise<unknown>} */
    async get(path) {
      const res = await fetch(baseUrl + path);
      if (!res.ok) throw new Error("HTTP " + res.status);
      return res.json();
    },
  };
}

/**
 * @param {ReturnType<typeof createClient>} client
 * @param {string} path
 * @param {(state: State) => void} onState
 */
export async function load(client, path, onState) {
  onState({ status: "loading" });
  try {
    const data = await client.get(path);
    onState({ status: "success", data });
  } catch (err) {
    onState({ status: "error", error: err.message });
  }
}

// call sites that now fail:
const c = createClient(42);
load(c, "/users/1", (state) => {
  if (state.status === "success") console.log(state.data.length);
});

npx tsc --noEmit now reports:

src/http-client.js:33:24 - error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.

33 const c = createClient(42);
                          ~~

src/http-client.js:35:54 - error TS18046: 'state.data' is of type 'unknown'.

35   if (state.status === "success") console.log(state.data.length);
                                                        ~~~~~~~~~~

Found 2 errors in the same file, starting at src/http-client.js:33

Step 4 — Rename to .ts and add explicit types

Once the JSDoc pass is clean, rename and move the annotations into the code.

git mv src/http-client.js src/http-client.ts
npx tsc --noEmit
// src/http-client.ts
export interface Client {
  get(path: string): Promise<unknown>;
}

export type State =
  | { status: "loading" }
  | { status: "success"; data: unknown }
  | { status: "error"; error: string };

export function createClient(baseUrl: string): Client {
  return {
    async get(path: string): Promise<unknown> {
      const res = await fetch(baseUrl + path);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return res.json();
    },
  };
}

export async function load(
  client: Client,
  path: string,
  onState: (state: State) => void,
): Promise<void> {
  onState({ status: "loading" });
  try {
    onState({ status: "success", data: await client.get(path) });
  } catch (err) {
    onState({ status: "error", error: err instanceof Error ? err.message : String(err) });
  }
}

Step 5 — Model the domain: a discriminated union and utility types

Replace the loose State with a proper discriminated union keyed on kind, add an idle arm, and derive the payload types instead of restating them. See Unions and Narrowing and Utility Types, plus the handbook’s Utility Types reference and Awaited.

export type RequestState<T> =
  | { readonly kind: "idle" }
  | { readonly kind: "loading" }
  | { readonly kind: "success"; readonly data: T }
  | { readonly kind: "error"; readonly error: string };

// Awaited unwraps the Promise: Json is `unknown`
type Json = Awaited<ReturnType<Client["get"]>>;

interface User {
  id: string;
  name: string;
  email: string;
  passwordHash: string;
}

export type PublicUser = Omit<User, "passwordHash">;          // id | name | email
export type UserPatch = Readonly<Pick<User, "name" | "email">>;

export function render(state: RequestState<PublicUser>): string {
  switch (state.kind) {
    case "idle":    return "";
    case "loading": return "Loading...";
    case "success": return state.data.name;                   // narrowed to the success arm
    case "error":   return state.error;
    default: {
      const _exhaustive: never = state;                       // fails to compile if an arm is added
      return _exhaustive;
    }
  }
}

Step 6 — Turn on strict and fix the fallout

Flip strict on (and noUncheckedIndexedAccess, which pairs well with it) and rebuild. See Type Design and Best Practices and the strict reference.

{
  "compilerOptions": {
    "target": "es2020",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noEmit": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

Two helpers that were fine under the loose config now fail:

function retryAfter(res: Response): number {
  const raw = res.headers.get("retry-after");   // string | null
  return parseInt(raw, 10);
}

function firstTag(data: string[]): string {
  return data[0].toUpperCase();
}
src/http-client.ts:44:19 - error TS2345: Argument of type 'string | null' is not assignable to parameter of type 'string'.
  Type 'null' is not assignable to type 'string'.

44   return parseInt(raw, 10);
                     ~~~

src/http-client.ts:48:10 - error TS18048: 'data[0]' is possibly 'undefined'.

48   return data[0].toUpperCase();
              ~~~~~~~

Found 2 errors in the same file, starting at src/http-client.ts:44

Handle the null and the possibly-missing element explicitly:

function retryAfter(res: Response): number {
  const raw = res.headers.get("retry-after");
  return raw === null ? 0 : parseInt(raw, 10);
}

function firstTag(data: readonly string[]): string {
  const first = data[0];
  return first === undefined ? "" : first.toUpperCase();
}

Step 7 — A satisfies check and a branded id

Use satisfies to validate a config object without widening its literal types, and a branded type so a UserId cannot be passed where any old string is expected. See Type Assertions and Modifiers and the satisfies operator.

type Method = "GET" | "POST" | "PUT" | "DELETE";

interface EndpointConfig {
  path: string;
  method: Method;
  timeoutMs: number;
}

const endpoints = {
  listUsers:  { path: "/users", method: "GET",  timeoutMs: 5_000 },
  createUser: { path: "/users", method: "POST", timeoutMs: 10_000 },
} satisfies Record<string, EndpointConfig>;

const m = endpoints.listUsers.method;   // type is "GET", not the wider Method
// endpoints.listUsers.timeoutMs = 0;   // still checked against EndpointConfig

// Branded id: a string the type system will not let you confuse with other strings
declare const brand: unique symbol;
export type UserId = string & { readonly [brand]: "UserId" };

export function toUserId(raw: string): UserId {
  return raw as UserId;                 // the one sanctioned assertion, at the boundary
}

function fetchUser(client: Client, id: UserId): Promise<unknown> {
  return client.get(`/users/${id}`);
}

// fetchUser(c, "u_123");               // Error: 'string' is not assignable to 'UserId'
fetchUser(c, toUserId("u_123"));        // OK

The literal \{ readonly [brand]: "UserId" } intersection carries no runtime cost — it exists only so the checker can tell a validated id from an arbitrary string.

Try it in the Playground

Paste any step above into the TypeScript Playground. Use the gear menu to toggle strict, allowJs, checkJs and noUncheckedIndexedAccess and watch the error list in Step 3 and Step 6 appear and disappear. The handbook’s Migrating from JavaScript guide covers the same path for a whole project rather than a single file.