TypeScript for JavaScript Developers: Where to Start

I explain the contract mindset behind TypeScript's checks, the annotations worth learning first, and how to migrate a JavaScript file one rename at a time.

TypeScript for JavaScript Developers: Where to Start

Rename a file from utils.js to utils.ts and the code still runs the same way. That’s the part nobody warns you about. The docs put it plainly: your existing working JavaScript code is also TypeScript code — nothing about the values changed. What changed is that a checker now reads your program before it runs, and it has opinions.

Take a classic JavaScript non-error: you read user.location off an object that has no location. JavaScript hands you undefined and keeps going; the bug waits patiently for later. The TypeScript Handbook uses exactly this example — a static type system flags it before the code ever executes. That move — from “discover at runtime” to “discover before running” — is the whole shift. Everything else is vocabulary.

Quick answer: Start with "strict": true in your tsconfig.json, let type inference do the work inside functions, and annotate only the boundaries — function parameters, return types, and object shapes. Adopt TypeScript one file at a time by renaming .js to .ts with allowJs enabled. The one thing types won’t do is validate data at runtime; that needs a separate step.

Series: TypeScript for JavaScript developers, part 1 — the opener. Each post stands alone.

On this page

Types are a contract, checked before your code runs

The mental model that made this click for me: a type is a contract about shape, and the compiler is the lawyer who reads every contract before anyone performs. formatPrice(amount: number) isn’t decoration — it’s a written promise that amount behaves like a number, checked against every call site you write, forever.

Here’s the reassuring part. You already write these contracts; they’re just unwritten. When you call message.toLowerCase(), you’re assuming a contract that says “this value has a callable toLowerCase.” JavaScript only proves that assumption by running the code and seeing what happens — the handbook calls this dynamic typing. A static system makes the assumption checkable up front.

And you barely have to write types to get started, because inference does most of it:

const title = "Refactor"; // inferred: string
let count = 0; // inferred: number

That’s straight from the docs’ own first examples — TypeScript infers string from the value without a single annotation. You annotate where inference can’t see the truth: function parameters, return types, object shapes crossing module boundaries.

The other half of the model matters just as much: types are erased. The handbook is emphatic here — “type annotations never change the runtime behavior of your program”. The compiler checks your contracts, strips every annotation, and emits plain JavaScript. No type ever executes. The runtime machinery I covered in JavaScript Execution Contexts Explained — call stacks, scopes, hoisting — operates in a world where types simply don’t exist.

Start strict, not loose

When you generate a tsconfig.json, one flag deserves your first decision: strict. The TSConfig Reference describes strict as a switch for the whole strict-mode family — noImplicitAny, strictNullChecks, noImplicitThis, strictFunctionTypes, and friends — all at once. The handbook’s advice is unambiguous: a new codebase should always turn these strictness checks on.

Two family members carry most of the weight:

  • noImplicitAny raises an error wherever the compiler would silently give up and infer any. Without it, every untyped parameter quietly becomes a hole where no checking happens at all.
  • strictNullChecks gives null and undefined their own types, so a value that might be absent has to say so — and you have to handle it before touching it.

The family still grows, which is why enabling the umbrella flag beats cherry-picking: strictBuiltinIteratorReturn joined the family in TypeScript 5.6. I’m writing this against TypeScript 5.7, released in November 2024, where strict remains the documented recommendation.

Migrating an existing project? Same answer, earlier. The migration guide says if you plan to use the stricter settings, it’s best to turn them on before you start modifying files — otherwise you’ll type everything twice.

The annotations you’ll write every day

Ninety percent of my annotations are function signatures, object shapes, and arrays. That’s the practical core:

type Article = {
  title: string;
  tags: string[];
  subtitle?: string; // optional — may be absent
};

function formatPrice(amount: number, currency: string): string {
  return `${currency}${amount.toFixed(2)}`;
}

Read formatPrice as a contract: takes a number and a string, returns a string. Now every call site gets checked against it, and your editor can complete .toFixed on amount because it knows what amount is.

Three notes worth internalising early:

  • Functions are the priority. Parameters and return types are where contracts live. Internal local variables can stay inferred — the handbook explicitly says it’s best not to annotate what inference already gets right.
  • Callbacks get parameter types too. (tag) => tag === name inside find needs tag to be typed before the comparison checks anything. Your callback is still a closure carrying its enclosing scope — the mechanics from Understanding Closures don’t change, they just get a typed shell.
  • Async functions return Promise<T>. Write Promise<string> when you annotate one, and await unwraps it for you — the flow from Promises, Async/Await, and the Event Loop maps straight onto the type.

Unions, literal types, and narrowing

This is where TypeScript starts earning real money, because unions describe situations JavaScript handles with guesswork:

type Theme = "light" | "dark" | "system";

Theme is a union of literal types — exactly three strings, nothing else. Misspell "dark" as "drak" and the compiler tells you immediately; comparing a "light" | "dark" value against "circle" produces a no-overlap error rather than a silent always-false branch.

Unions pair with the feature I’d call the single most useful thing in the language: narrowing. TypeScript watches your runtime checks and refines the type inside each branch:

function wrap(input: string | string[]): string[] {
  if (typeof input === "string") {
    return [input]; // input: string here
  }
  return input; // input: string[] here
}

typeof, instanceof (classes, Date, errors), and in (does this object have that property) all narrow. These are checks you already write in JavaScript — TypeScript just starts listening to them.

My favourite narrowing payoff connects to my array methods post: find doesn’t promise a match. With strictNullChecks on, its return type is string | undefined, and the compiler forces you to handle the miss case that JavaScript let you ignore. One warning from the docs worth repeating: typeof null === "object" is a historical accident, so checking for "object" doesn’t remove null from a union. The compiler knows this quirk; now you do too.

Migrate one file at a time

You don’t convert a project. You convert a file, then another file. The official migration path is deliberately boring:

  1. Add a tsconfig.json with allowJs: true so JavaScript and TypeScript coexist.
  2. Rename one .js file to .ts. That’s the entire migration step for that file.
  3. Expect red squiggles. The guide compares them to spell-check — the compiler still emits JavaScript even with errors present, because your code was working before you invited the checker in.
  4. Work the errors down. Install @types/* packages where libraries need declarations.
  5. Repeat with the next file.

Start with leaf utilities — small, dependency-light modules where contracts are easy to state. If you want errors to block compilation instead of just warning, noEmitOnError is the dial for that.

{
  "compilerOptions": {
    "strict": true,
    "allowJs": true,
    "outDir": "./built"
  },
  "include": ["./src/**/*"]
}

What types can’t catch before runtime

Here’s the limitation that surprises people who skipped the erasure part: since types vanish at compile time, they cannot check anything about values that arrive while the program runs. JSON.parse, fetch responses, form input, process.env — the compiler has never seen this data. Annotate the result as Article all you like; the annotation is a claim, not a check. A as Article assertion is worse — it silences the question without answering it.

The complement is runtime validation: a schema that actually inspects the data. I reach for Zod, a TypeScript-first validation library where you define a schema, parse unknown input through it, and get back a value the compiler will treat as typed:

import * as z from "zod";

const Article = z.object({ title: z.string(), tags: z.array(z.string()) });
const parsed = Article.parse(JSON.parse(raw)); // throws if the shape is wrong

Compile-time contracts and runtime validation are a pair, not rivals. Fittingly, Zod’s own documentation requires strict mode in your tsconfig.json — the ecosystem assumes the baseline from earlier.

Where JavaScript developers get stuck

Four failure patterns account for most of the frustration I see discussed around starting out:

Annotating everything. Fighting inference makes TypeScript feel like paperwork. Annotate boundaries; trust inference locally.

Reaching for any or as at the first red squiggle. Both are “stop checking me” buttons. Sometimes that’s the honest answer mid-migration — but each one quietly removes a contract, and they accumulate into Swiss cheese.

Expecting types at runtime. There is no if (typeof x === "Article"). Types are gone by then; the narrowing section above is about JavaScript checks the compiler understands, not new runtime machinery.

Turning strict off when migration stings. It’s tempting on day three, when a hundred null checks surface at once. The migration guide’s ordering advice exists precisely to avoid this — strict first, then fix forward, so you never type the same file twice.

Check this before moving on

  • npx tsc --version prints 5.7 or later, so docs and errors match what you read.
  • Your tsconfig.json contains "strict": true and allowJs if JavaScript files remain.
  • A renamed .ts file compiles and its emitted .js output still runs.
  • One function signature has annotated parameters and a return type.
  • One union type narrows through a typeof, instanceof, or in check.
  • One piece of external data (parsed JSON or similar) goes through a runtime validator before use.

Learn contracts first, syntax second

The developers who bounce off TypeScript treat it as a syntax tax — annotations to memorise, errors to appease. The ones who stick treat it as a contract system they were already maintaining in their heads, finally written down and enforced before every run. Once you see a function signature as a promise, and erasure as the reason runtime data needs its own validation, the rest is deliberate practice.

The syntax worth practising comes next in this series: when to use interface versus type (the docs suggest preferring interface and using type when you need its special abilities); generics, which are just variables for types — Array<string> is the one you’ve already used; and the utility types like Partial and Pick that build new contracts from old ones. None of them change today’s model; they extend it.

Your one action: rename a single .js file to .ts with strict on, and work its error list to zero. The checker’s first pass on your own code will teach you more than any post can — this one included.

Sources