TS and JS Interoperability — Practical Tasks¶
Hands-on tasks ordered from junior to professional. Each task has a goal, requirements, starter context, and acceptance criteria. Do them in a scratch repo with TypeScript installed (
npm i -D typescript). Verify with the commands shown.
Table of Contents¶
- Task 1 — Junior: Rename JS to TS
- Task 2 — Junior: Enable allowJs + checkJs
- Task 3 — Junior: Type a JS File with JSDoc
- Task 4 — Junior: Consume an @types Package
- Task 5 — Middle: Write a Hand-Made .d.ts
- Task 6 — Middle: Fix a CJS Default Import
- Task 7 — Middle: Shim an Untyped Module
- Task 8 — Middle: Suppress Safely with @ts-expect-error
- Task 9 — Senior: Global Augmentation for process.env
- Task 10 — Senior: Patch a Wrong @types Package
- Task 11 — Senior: Generate .d.ts for a TS Library
- Task 12 — Professional: Dual CJS/ESM Library
- Task 13 — Middle: Type a Wildcard Asset Import
- Task 14 — Senior: JSDoc Generics and @callback
- Task 15 — Professional: Verify Published Types with attw
- Mini Projects
- Challenge
- Stretch Goals
Task 1 — Junior: Rename JS to TS¶
Goal: Convert a plain JS file to TypeScript and add types, confirming the superset guarantee.
Requirements: - Start from a working .js file. - Rename it to .ts; it must still compile unchanged. - Then add type annotations.
Starter:
// user.js (before)
// function makeUser(name, age) {
// return { name, age };
// }
// module.exports = { makeUser };
// user.ts (after rename + types)
interface User {
name: string;
age: number;
}
export function makeUser(name: string, age: number): User {
return { name, age };
}
Acceptance Criteria: - [ ] The renamed file compiles with npx tsc --noEmit user.ts. - [ ] makeUser("Ada", "old") is now a compile error. - [ ] The return type is User, verified by hovering or tsc.
Task 2 — Junior: Enable allowJs + checkJs¶
Goal: Make TypeScript include and check existing JavaScript.
Requirements: - Create a tsconfig.json enabling allowJs and checkJs with noEmit. - Have one .js file with an obvious type bug.
Starter:
Reference config:
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"noEmit": true,
"strict": true
},
"include": ["**/*.js"]
}
Acceptance Criteria: - [ ] npx tsc reports an error on double("not a number"). - [ ] Removing checkJs makes the error disappear. - [ ] Adding // @ts-nocheck to the top of legacy.js also silences it.
Task 3 — Junior: Type a JS File with JSDoc¶
Goal: Get type safety in a .js file without converting it.
Requirements: - Add // @ts-check and JSDoc to a plain JS module. - Type the parameters and return value.
Starter:
// @ts-check
// geometry.js
/**
* @param {number} width
* @param {number} height
* @returns {number}
*/
function area(width, height) {
return width * height;
}
area(3, 4); // OK
area("3", 4); // should error
Acceptance Criteria: - [ ] area("3", 4) reports a type error. - [ ] A @typedef is added for a Rectangle object and used in a second function. - [ ] The file remains a .js file (no conversion).
Task 4 — Junior: Consume an @types Package¶
Goal: Install and use community types for an untyped library.
Requirements: - Install a JS-only library plus its @types package. - Use a typed function from it.
Starter:
import { chunk } from "lodash";
const pages: number[][] = chunk([1, 2, 3, 4, 5], 2);
console.log(pages);
Acceptance Criteria: - [ ] pages is typed as number[][] (hover to confirm). - [ ] chunk([1,2,3], "2") is a compile error. - [ ] @types/lodash appears under devDependencies, not dependencies.
Task 5 — Middle: Write a Hand-Made .d.ts¶
Goal: Describe an untyped JS module with a declaration file you author.
Requirements: - Given a JS module with no types, write a matching .d.ts. - Cover its exported function and a constant.
Starter:
// legacy-lib.js (cannot modify)
// module.exports = {
// version: "1.2.3",
// formatName(first, last) { return first + " " + last; }
// };
// legacy-lib.d.ts — author this
declare const legacyLib: {
version: string;
formatName(first: string, last: string): string;
};
export = legacyLib;
Acceptance Criteria: - [ ] import legacyLib = require("./legacy-lib"); type-checks. - [ ] legacyLib.formatName("Ada") errors (missing argument). - [ ] The .d.ts contains no runtime code.
Task 6 — Middle: Fix a CJS Default Import¶
Goal: Resolve a CommonJS default-import error with the right config flag.
Requirements: - Reproduce the TS1259/TS2497 default-import error. - Fix it with esModuleInterop.
Starter:
// server.ts — fails without interop
import express from "express";
const app = express();
app.get("/", (_req, res) => res.send("ok"));
Acceptance Criteria: - [ ] With esModuleInterop:false, tsc errors on the default import. - [ ] With esModuleInterop:true, it compiles. - [ ] You can explain what allowSyntheticDefaultImports would have done differently (type-only, no emit change).
Task 7 — Middle: Shim an Untyped Module¶
Goal: Make a truly untyped import compile under noImplicitAny.
Requirements: - Import a package with no types and no @types. - Add an ambient module declaration so it stops erroring.
Starter:
// types/cool-lib.d.ts
declare module "cool-lib" {
export function doCoolThing(input: string): string;
}
// app.ts
import { doCoolThing } from "cool-lib";
console.log(doCoolThing("hi"));
Acceptance Criteria: - [ ] Without the shim, tsc reports TS7016. - [ ] With the shim, doCoolThing is typed (input: string) => string. - [ ] A bare declare module "cool-lib"; is shown as the any fallback alternative.
Task 8 — Middle: Suppress Safely with @ts-expect-error¶
Goal: Replace risky suppressions with self-cleaning ones.
Requirements: - Find a line suppressed by @ts-ignore. - Replace with @ts-expect-error plus a reason. - Demonstrate the self-cleaning behavior.
Starter:
// @ts-expect-error -- @types/foo@1.0 is missing the `retries` option (issue #421)
risky({ retries: 3 });
Acceptance Criteria: - [ ] Removing the (simulated) underlying error makes @ts-expect-error itself error (TS2578). - [ ] Every suppression in the file carries a -- reason comment. - [ ] No @ts-ignore remains.
Task 9 — Senior: Global Augmentation for process.env¶
Goal: Add typed environment variables via global augmentation.
Requirements: - Add typed keys to NodeJS.ProcessEnv. - Confirm autocomplete and error on missing keys.
Starter:
// env.d.ts
declare global {
namespace NodeJS {
interface ProcessEnv {
API_URL: string;
PORT: string;
}
}
}
export {};
Acceptance Criteria: - [ ] process.env.API_URL autocompletes and is typed string. - [ ] process.env.NOPE is flagged when strict/index checks are on (or is string | undefined as configured). - [ ] The export {} is present so declare global is valid.
Task 10 — Senior: Patch a Wrong @types Package¶
Goal: Correct an incorrect third-party type with module augmentation.
Requirements: - Given an @types package that declares a field non-nullable when it is actually nullable, fix the type without forking.
Starter:
// patches/widget-lib.d.ts
import "widget-lib";
declare module "widget-lib" {
interface Widget {
// upstream says `label: string`; runtime can be null
label: string | null;
}
}
Acceptance Criteria: - [ ] After the augmentation, widget.label is string | null. - [ ] Code that assumed non-null now requires a null check. - [ ] The patch file is included via include/typeRoots.
Task 11 — Senior: Generate .d.ts for a TS Library¶
Goal: Ship types for a TypeScript library so JS and TS consumers both benefit.
Requirements: - Enable declaration emit. - Wire package.json types to the emitted file.
Starter:
{
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"outDir": "dist",
"rootDir": "src"
}
}
Acceptance Criteria: - [ ] npx tsc emits dist/index.d.ts (and .d.ts.map). - [ ] A consumer importing the package gets full IntelliSense. - [ ] emitDeclarationOnly is shown as the types-only variant.
Task 12 — Professional: Dual CJS/ESM Library¶
Goal: Publish a library consumable as both CommonJS and ESM with correct types.
Requirements: - Produce a .cjs build and an .mjs build (two configs or .cts/.mts). - Provide a package.json exports map with import/require/types conditions. - Validate the published types.
Starter:
// package.json
{
"name": "dual-lib",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
Acceptance Criteria: - [ ] An ESM consumer (import x from "dual-lib") resolves to the .mjs build. - [ ] A CJS consumer (require("dual-lib")) resolves to the .cjs build. - [ ] verbatimModuleSyntax is on for predictable emit. - [ ] npx @arethetypeswrong/cli (or equivalent) reports no interop problems.
Task 13 — Middle: Type a Wildcard Asset Import¶
Goal: Make non-code imports (handled by a bundler) type-check in TypeScript.
Requirements: - Write a wildcard ambient module for *.svg (and *.css). - Import an asset and use the typed default.
Starter:
// shims.d.ts
declare module "*.svg" {
const url: string;
export default url;
}
declare module "*.css" {
const classes: Record<string, string>;
export default classes;
}
// logo.ts
import logoUrl from "./logo.svg";
const img = { src: logoUrl };
Acceptance Criteria: - [ ] import logoUrl from "./logo.svg" type-checks as string. - [ ] Without the shim, the import errors. - [ ] The shim is discovered via include/typeRoots.
Task 14 — Senior: JSDoc Generics and @callback¶
Goal: Express a generic function and a function-type alias in JSDoc only.
Requirements: - Write a generic identity and a @callback-typed reducer in a checked .js file.
Starter:
// @ts-check
/**
* @template T
* @param {T} value
* @returns {T}
*/
function identity(value) {
return value;
}
/**
* @callback Reducer
* @param {number} acc
* @param {number} cur
* @returns {number}
*/
/** @type {Reducer} */
const add = (a, b) => a + b;
Acceptance Criteria: - [ ] identity(5) is typed number; identity("x") is typed string. - [ ] add("1", 2) errors. - [ ] The file stays .js with // @ts-check.
Task 15 — Professional: Verify Published Types with attw¶
Goal: Catch interop problems in a package's published types before release.
Requirements: - Build a small dual-format package. - Run @arethetypeswrong/cli and resolve any reported issues.
Starter:
Acceptance Criteria: - [ ] attw runs and produces a report. - [ ] Any "masquerading as CJS/ESM" or "no types" findings are fixed via exports conditions and .d.cts/.d.mts. - [ ] The final report is clean for both import and require consumers.
Mini Projects¶
Mini Project A — Gradual Migration of a Small Utility Lib¶
Build a string-utils package starting in JS and migrate it to TS:
- Begin with
src/*.js,allowJs:true,checkJs:false, building successfully. - Add
// @ts-check+ JSDoc to each file one at a time; fix surfaced errors. - Flip
checkJs:trueglobally; the build must stay green. - Convert files to
.tsleaf-first; addinterfaces. - Enable
strict:truelast.
Done when: every file is .ts, strict is on, tsc --noEmit is clean, and you can show the git history of incremental conversion.
Mini Project B — Typed Wrapper Around an Untyped SDK¶
Pick an untyped npm package. Write a wrapper.ts that:
- Adds a
declare moduleshim (or a real.d.ts) covering only the API you use. - Re-exports a narrow, strictly-typed surface.
- Validates inputs/outputs at the boundary with a runtime check.
Done when: consumers import only your typed wrapper, never the raw untyped module.
Challenge¶
Build an interop linting gate.
Create a small repo that enforces interop hygiene in CI:
tsconfig.jsonwithallowJs,checkJs,strict,noImplicitAny,skipLibCheck.- A lint rule (eslint
@typescript-eslint/ban-ts-comment) that bans bare@ts-ignoreand requires a description on@ts-expect-error. - A CI job running
tsc --noEmitthat fails on any new untyped import (TS7016). - A second CI job for your own emitted
.d.tsthat runs withoutskipLibCheck. - A documented
@typesversion-alignment check (npm lsscript that fails on major mismatch).
Acceptance Criteria: - [ ] A PR adding a bare @ts-ignore fails CI. - [ ] A PR importing an untyped lib without a shim fails CI. - [ ] A broken self-authored .d.ts is caught by the no-skipLibCheck job. - [ ] The README documents the gradual-migration policy the gate enforces.
Stretch Goals¶
- Convert a
.jsJSDoc-typed file into an equivalent.tsfile and diff the type errors. - Write a UMD declaration file for a library usable both as a global and a module.
- Reproduce and fix a
TS5055overwrite error by introducingoutDir. - Set up
node16resolution and fix all relative imports to include.jsextensions. - Use
@arethetypeswrong/clion a real published package and interpret its report.