03 β Your First TypeScript Program
Level: Beginner Β· Time: ~25 min Β· Prerequisites: Lesson 2 β Your Toolchain β Node, npm, VS Code and the Compiler
Why this matters
Everything from here on is one loop repeated: edit a file, let the compiler object, fix it, run it. Learning to read a diagnostic properly β file, position, code, the what and the why β turns a red squiggle from an obstacle into the fastest bug detector you will own. The second distinction this lesson makes concrete separates engineers from people who merely use TypeScript: a compile error and a runtime crash are different failures, because the compiler knows your code and nothing at all about your data.
The loop: two ways from source to output
| Path | Commands | What happens to your types |
|---|---|---|
| Compile, then run | npm run build && node dist/index.js |
Checked, then erased β the output is plain JavaScript |
| Run directly | npx tsx src/index.ts |
Erased only. No checking at all |
Develop with npx tsx watch src/index.ts, but treat npm run typecheck as the authority. When both agree, npm run build produces the artefact you would ship.
Building the tracker, end to end
Three days of yield β the numbers you would read off an inverter or download from PVGIS.
src/index.ts:
type DailyYield = { readonly day: string; readonly kwh: number };
const yields: readonly DailyYield[] = [
{ day: "2026-03-01", kwh: 18.4 },
{ day: "2026-03-02", kwh: 21.9 },
{ day: "2026-03-03", kwh: 12.6 },
];
const totalKwh = yields.reduce((sum, entry) => sum + entry.kwh, 0);
const averageKwh = totalKwh / yields.length;
console.log(`Three-day total: ${totalKwh.toFixed(1)} kWh`);
console.log(`Daily average: ${averageKwh.toFixed(1)} kWh`);
Notice what is missing: no annotation on sum, none on entry, none on totalKwh β the compiler infers all of them from yields. That is the habit this course teaches: annotate the data shapes, infer the arithmetic.
npm run typecheck # silence means success
npm run build
node dist/index.js
npx tsx src/index.ts # the same program, without an output folder
Expected output, from both runs:
Reading a compiler error like a professional
Break the program deliberately: change the first reading from 18.4 to "18.4" β a string, exactly what a CSV column or a JSON field hands you β and run npm run typecheck:
Five pieces of information, in the order you should read them:
src/index.tsβ the file. With dozens of files, this alone saves minutes.(4,24)β line 4, column 24: the exact position of the offending expression (the"18.4"you just typed).TS2322β the diagnostic code, stable and searchable, and what the editor offers a quick fix for.Type 'string' is not assignable to type 'number'β the what: what you provided, and what was required. Read it left to right.- The why is underneath β a follow-up line, or the hover text naming the declaration that
demanded a
number. Here it isDailyYield.kwh.
Two more messages you will meet within the first hour: error TS2345: Argument of type 'string' is not assignable to parameter of type 'readonly DailyYield[]'., which means the call site is wrong; and error TS7006: Parameter 'entry' implicitly has an 'any' type., which means the compiler could not infer a parameter and gave up β that function body is now unchecked, which is worse than an error.
[!TIP] Fix one error, then re-run the check. A single wrong type can produce thirty downstream diagnostics, and chasing them in parallel is how people edit code that was never broken.
What strict: true actually turns on
strict is not one check but a bundle, and each member closes a class of bug that JavaScript ships by default.
Flag inside strict |
The bug it stops |
|---|---|
strictNullChecks |
Reading null or undefined as if it were a value β the commonest crash in JavaScript |
noImplicitAny |
A parameter the compiler could not type, silently unchecked for the whole body |
strictFunctionTypes |
Callbacks whose parameters are accepted unsoundly, so a string reaches a number |
strictPropertyInitialization |
A class field used before anything assigned it |
useUnknownInCatchVariables |
Assuming a caught error is an Error, when anything can be thrown |
strictNullChecks is the one that changes how code looks: null becomes a real type, and a value that might be missing must be handled before use.
function kwhPerDay(totalKwh: number, days: number): number {
if (days === 0) return 0; // the guard the compiler forced you to write
return totalKwh / days;
}
const maybeDays: number | undefined = undefined;
kwhPerDay(52.9, maybeDays ?? 3); // β
the union was handled explicitly
// kwhPerDay(52.9, maybeDays); β TS2345: 'number | undefined' is not assignable to 'number'
A compile error is not a runtime crash
A compile error means the compiler read your source and refused to emit β nothing ran. A runtime crash means the program started, ran, and then threw: the expensive failure, and the one the compiler cannot always prevent, because types describe code, not data.
// This compiles cleanly. It crashes.
type Reading = { readonly region: string; readonly kwh: number };
const raw = JSON.parse('{"region": "Dakar"}') as Reading;
console.log(raw.kwh.toFixed(1));
// TypeError: Cannot read properties of undefined (reading 'toFixed')
The as Reading is a promise you made to the compiler and the JSON did not keep. Data from a file, a network response, an environment variable or a user must be validated at runtime; Lesson 23 shows how.
The mirror image is code the compiler rejects that would otherwise have crashed: days[5] on a
three-element array is undefined, and crashing one call later β with noUncheckedIndexedAccess the checker stops you at the index instead.
Debugging, and where annotations belong
"sourceMap": true from Lesson 2 makes dist/index.js.map link the emitted JavaScript back to
src/index.ts. With that in place, .vscode/launch.json gives you breakpoints in your own source:
{
"version": "0.2.0",
"configurations": [
{ "type": "node", "request": "launch", "name": "Debug built program",
"program": "${workspaceFolder}/dist/index.js",
"outFiles": ["${workspaceFolder}/dist/**/*.js"], "sourceMaps": true }
]
}
Build first (npm run build), then press F5. Outside the editor, node --inspect dist/index.js prints
an address a Chromium browser's chrome://inspect page can attach to, and node --inspect-brk stops
before the first line β what you want when the program dies during start-up.
Where to annotate. The rule is: annotate the boundaries, infer the interior. Type the function parameters, the return types and the shapes of data entering the program; let the compiler infer everything inside a body and every value derived from something already typed. Inference is never stale; a hand-written annotation is. While debugging, log the value (yields) rather than a description of it ("checking yields").
Common mistakes
| Mistake | What happens | The fix |
|---|---|---|
Treating tsx output as proof the code is correct |
Type errors survive, because tsx never checks |
Run npm run typecheck as the gate before every commit |
| Fixing all errors at once | Half of them vanish when the first is fixed, and you change code that was fine | Fix the first error, re-run, repeat |
Reaching for as to silence a diagnostic |
It compiles and the crash becomes invisible β the worst trade in TypeScript | Handle the type the compiler found: a guard, a default, a union |
Assuming JSON.parse results are typed |
any flows through unchecked and fails at the worst moment |
Treat the result as unknown and validate it (Lesson 23) |
| Debugging built output without source maps | Breakpoints land in the wrong place and line numbers lie | Keep sourceMap: true and outFiles pointing at dist/**/*.js |
Adding ! to a possibly-undefined value to move on |
The crash happens later, further from its cause | Guard it now: days[0] ?? "", or return early |
Try it
- Add a function above the
console.loglines, with explicit boundary types:
function highestDay(entries: readonly DailyYield[]): DailyYield | undefined {
let best: DailyYield | undefined = undefined;
for (const entry of entries) if (best === undefined || entry.kwh > best.kwh) best = entry;
return best;
}
const best = highestDay(yields);
console.log(`Best day: ${best?.day ?? "no data"} (${best?.kwh.toFixed(1) ?? "0.0"} kWh)`);
The | undefined is not decoration: an empty array has no best day, and strictNullChecks forces you to say so.
- Run the loop and confirm the fourth line:
Expected:
-
Break it three ways, one at a time, recording the code and wording of each message before fixing it: change
18.4to"18.4"(expectTS2322); callhighestDay(52.9)(expectTS2345); replace the last line withconsole.log(best.kwh)(expect a "possibly 'undefined'" error). -
Set a breakpoint on the
if (best === undefined ...)line, runnpm run build, press F5. Expected: the debugger stops insidesrc/index.ts, not indist/index.jsβ the source map doing its work.
Key takeaways
- The compiler knows your code, not your data. A clean
npm run typechecksays nothing about the JSON or CSV your program will read at runtime. - Every diagnostic carries five things, read in order: file, position, code, the what, the why.
strict: trueis a bundle, andstrictNullChecksandnoImplicitAnyaccount for most of the bugs it prevents.- Compile errors are cheap because nothing ran; runtime crashes are expensive because something did.
- Annotate boundaries and infer the interior β TypeScript is not more annotated code, it is better-placed annotations.
Check yourself
tsxprinted a number thattscrejects. What did each tool do, and which one told the truth about the program?- Why does
highestDayreturnDailyYield | undefinedrather thanDailyYield? What would the second version cost you at a call site written a month later? const raw = JSON.parse(text) as Readingcompiles and crashes. Where exactly is the lie, and which technique removes it?- You have a working
tsx watchsession and a failingnpm run typecheck. Which do you believe, and what would you change about your workflow so the question cannot arise?