TypeScript Syntax: the JavaScript Developer's Delta

Here is the encouraging news this lesson is built on: you already know most of TypeScript's syntax, because it is JavaScript's syntax. TypeScript adds exactly one new category — type-level syntax — layered on top of the language you write every day. This lesson maps that delta precisely, so the rest of the track reads like JavaScript with annotations rather than a new language.

What carries over unchanged

Every statement, operator, and construct from modern JavaScript works identically in TypeScript: if/switch, for and for…of, arrow functions, destructuring, spread, template literals, optional chaining (?.), nullish coalescing (??await. The compiler parses them all — because they are JavaScript.

The same JavaScript, with one addition

Purpose of the example: see how little actually changes between the JS you write today and the TS you will write tomorrow.

// JavaScript you already know — unchanged in TypeScript:
const retries = 3;
const names = ["Ada", "Linus"];
const shout = (s) => s.toUpperCase();   // (JS: implicit any param)

// TypeScript adds only annotations — a second channel of information:
const retries: number = 3;              // : number — the only addition
const names: string[] = ["Ada", "Linus"];
const shout = (s: string): string => s.toUpperCase();
//            ^^^^^^^^^            ^^^^^^
//            param type           return type

What you should see: identical runtime behavior — annotations vanish (the compiler lesson showed the erasure). The right-hand side is byte-for-byte your JavaScript; the colon syntax is metadata about it.

Left column: JavaScript statement syntax. Right column: the TypeScript additions — annotations, generics, type aliases, assertion operators
Figure 1 — The syntax delta. Left: constructs JavaScript already gave you. Right: the TypeScript-only layer — and every symbol on the right is erased at compile time.

The annotation layer

Annotations attach types to existing JavaScript positions. There are only a handful of positions to learn.

Where annotations attach

Purpose of the example: cover every annotation position in one file — variables, function parameters, return values, and class members.

// 1. Variables — annotate when the initializer doesn't make the type obvious
let port: number = 8080;
let host: string | undefined = undefined;   // union: one OR the other

// 2. Function parameters and return type
function clamp(value: number, min: number, max: number): number {
  return Math.min(Math.max(value, min), max);
}

// 3. Arrow function — the return type goes after the parameter list
const clampArrow = (value: number, lo: number, hi: number): number =>
  Math.min(Math.max(value, lo), hi);

// 4. Class members
class Server {
  port: number = 8080;              // property annotation + initializer
  startedAt?: Date;                 // ? = the property may be absent
}

What you should see: the checker now rejects port = "8080" and clamp(1, 10) (missing argument) before the code ever runs. In practice you write fewer annotations than you expect — initializers like let port = 8080 are inferred as number without help. Annotate boundaries (public functions, exported values); let inference handle the internals.

type and interface: naming shapes

Inline annotations scale poorly. type and interface give a shape a name — these keywords are TypeScript-only and exist purely on the type channel.

Purpose of the example: the two declaration forms, and when each reads better.

// interface: describes an object shape — can be extended via declaration merging
interface User {
  id: number;
  name: string;
  email?: string;          // optional: string | absent
}

// type: names ANY type — unions, primitives, tuples, mapped types
type Role = "admin" | "editor" | "viewer";   // union of literals
type Id = number | string;
type Pair = [User, Role];                    // tuple type

const promote = (u: User, r: Role): User => ({ ...u });
// ERROR: promote({ id: 1, name: "Ada" }, "owner")
//                      ^^^^^^^ "owner" is not assignable to Role

What you should see: the checker rejecting any role outside the three literal strings — the exact bug class free-text strings cause in JavaScript. Start with interface for object shapes and type for everything else; the interfaces lesson compares them in depth.

The type-operator layer

On top of annotations, TypeScript contributes a small set of operators that work on types. These are the symbols that make TypeScript code look foreign at first — and they are all erased at compile time, exactly like annotations.

Union, intersection, and literal types

Purpose of the example: the two combination operators, plus the literal-type idiom that replaces stringly-typed JavaScript.

// | : union — a value is ONE of the listed types
type Status = "idle" | "loading" | "error";  // literal union
type Id = string | number;

// & : intersection — a value must satisfy ALL listed types
type Serializable = { toJSON(): string };
type Identifiable = { id: string };
type Entity = Identifiable & Serializable;  // both required

// Literal types pin a value to exact strings/numbers —
// the replacement for magic constants scattered in JS:
let state: Status = "idle";
// state = "loaded";   // ERROR: "loaded" is not assignable to Status

What you should see: Status behaves like an enum from other languages but is erased to nothing — the emitted JavaScript is a plain string. This is why modern TypeScript prefers literal unions over enum.

typeof, keyof, and indexed access

Purpose of the example: derive types from other types instead of re-declaring them — the habit that keeps types honest as code evolves.

const settings = {
  theme: "dark" as "dark" | "light",
  retries: 3,
  verbose: false,
};

// typeof (type position): the type of a VALUE
type Settings = typeof settings;
//   = { theme: "dark" | "light"; retries: number; verbose: boolean }

// keyof: the union of an object type's property names
type SettingKey = keyof Settings;   // "theme" | "retries" | "verbose"

// Indexed access: the type at a given key
type Theme = Settings["theme"];     // "dark" | "light"

function get<K extends keyof Settings>(key: K): Settings[K] {
  return settings[key];   // checker links key to the returned type
}

What you should see: rename a property in settings and every derived type follows — there is no second declaration to drift out of sync. This is the foundation the advanced types lesson builds on.

The assertion escape hatches

Two constructs let you override the checker. They look similar and do opposite-accuracy jobs; confusing them is a classic source of runtime bugs.

as assertions versus satisfies

Purpose of the example: show when to use as, when satisfies is strictly better, and why assertions are a last resort.

interface Config { port: number; host: string }

// `as`: "treat this as Config — I take responsibility."
// The checker does NOT verify the object against Config.
const cfg1 = JSON.parse(raw) as Config;      // unchecked cast

// `satisfies`: "check this value against Config, but keep the
// inferred (more precise) type." Verifies AND remembers.
const cfg2 = {
  port: 8080,
  host: "localhost",
} satisfies Config;          // checked: missing/misspelled keys error here

// satisfies also preserves literal types for later use:
const routes = {
  home: "/",
  admin: "/admin",
} satisfies Record<string, string>;
// routes.home is still exactly "/" — not widened to string

What you should see: with as, a typo'd or missing property sails through compilation and explodes at runtime — erasure means the cast left no trace. With satisfies, the same mistake is a compile error. Rule of thumb: satisfies first, as only when the checker genuinely cannot know (rare), any never.

Non-null and optional access

Purpose of the example: the daily-driver operators for optional data — one promises, one checks.

type User = { name?: string };

function label(u: User): string {
  // ?. : access only if present — evaluates to undefined otherwise
  const len = u.name?.length;

  // ?? : fall back when null/undefined (NOT on "" or 0 — unlike ||)
  return u.name ?? "anonymous";
}

function shout(u: User): string {
  // ! : non-null assertion — "this is NOT null/undefined, trust me".
  // Unchecked, like `as`. Crashes at runtime if you are wrong.
  return u.name!.toUpperCase();
}

What you should see: label handles absence honestly; shout throws TypeError: Cannot read properties of undefined if name is missing — the assertion disabled exactly the check that would have caught it. Prefer narrowing (if (u.name) …) over !; the types lesson shows how narrowing replaces most assertions.

A one-page syntax table

The delta, compressed. When you meet an unfamiliar symbol in real code, look it up here first.

Punctuation, and what it belongs to

SyntaxLayerMeaningErased?
x: TTypeScriptannotation — attach type T to xyes
T1 | T2TypeScriptunion — one of the alternativesyes
T1 & T2TypeScriptintersection — all of them combinedyes
<T>TypeScriptgenerics — type parametersyes
keyof TTypeScriptunion of property names of Tyes
typeof v (type position)TypeScriptthe type of value vyes
v as TTypeScriptassertion — unchecked castyes
v satisfies TTypeScriptchecked cast, keeps precisionyes
v!TypeScriptnon-null assertion — uncheckedyes
prop?: TTypeScriptoptional property — T | absentyes
readonly prop: TTypeScriptno reassignment on the type channelyes
v?.p, v ?? dJavaScriptoptional chaining, nullish coalescingno — runtime
? : (ternary)JavaScriptconditional expressionno — runtime

What we learn: the runtime language is unchanged — ?. and ?? execute, while everything from the annotation layer onward compiles away. When debugging a runtime failure, ignore the type channel entirely; when reviewing code, read it first.

Reading type signatures aloud

Practitioners read type signatures the way musicians read notation. Decode this one as practice:

function pluck<T, K extends keyof T>(obj: T, keys: K[]): T[K][] {
  return keys.map((k) => obj[k]);
}
// Read: "pluck takes an object T and an array of keys K, where every K
// must be a property name of T, and returns an array of the values
// at those keys — so the result's element types match the keys."

What you should see: pluck({ a: 1, b: "x" }, ["a"]) returns number[], and the checker rejects pluck(obj, ["c"]) — "c" is not a keyof. Every symbol here was introduced in this lesson; if any felt unfamiliar, the table above is your review sheet.

Practice: write the delta, not the language

Migrate a snippet

Take this plain JavaScript and add the minimal annotations that make it check under strict mode — no more:

function formatPrice(item) {
  return `$${(item.price * item.qty).toFixed(2)}`;
}

What you should see: an Item interface (price: number, qty: number), the parameter annotated, and the return type left to inference — string is obvious. If you annotated the return type too, you wrote more than the minimal delta; if you reached for any, revisit the assertions section.

Predict what erases

From your migrated snippet, list every token that survives to the emitted JavaScript. The interface disappears, both annotations disappear, and formatPrice remains byte-identical to the original. Holding that duality — design-time channel vs runtime channel — is the skill the next lesson, on the type system itself, will exercise constantly.