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.