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.
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
| Syntax | Layer | Meaning | Erased? |
|---|---|---|---|
x: T | TypeScript | annotation — attach type T to x | yes |
T1 | T2 | TypeScript | union — one of the alternatives | yes |
T1 & T2 | TypeScript | intersection — all of them combined | yes |
<T> | TypeScript | generics — type parameters | yes |
keyof T | TypeScript | union of property names of T | yes |
typeof v (type position) | TypeScript | the type of value v | yes |
v as T | TypeScript | assertion — unchecked cast | yes |
v satisfies T | TypeScript | checked cast, keeps precision | yes |
v! | TypeScript | non-null assertion — unchecked | yes |
prop?: T | TypeScript | optional property — T | absent | yes |
readonly prop: T | TypeScript | no reassignment on the type channel | yes |
v?.p, v ?? d | JavaScript | optional chaining, nullish coalescing | no — runtime |
? : (ternary) | JavaScript | conditional expression | no — 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.