TypeScript Get Started
TypeScript is a superset of JavaScript that adds a static type system on top of the language you already know. You write TypeScript in .ts files, and a compiler called tsc checks your code for type mistakes and then strips the types away, producing plain JavaScript that runs anywhere JavaScript runs: Node.js, browsers, or any other JS engine. The value isn’t a new runtime or a new language to execute — it’s catching bugs (wrong argument types, typos in property names, forgotten null checks) before your code ever runs, plus far better autocomplete and refactoring support in your editor. This lesson gets you from zero to writing, compiling, and understanding your first TypeScript programs.
Overview: How TypeScript Works
TypeScript is not a separate language that gets interpreted at runtime. It is JavaScript plus an optional type annotation syntax, plus a compiler (tsc) that performs static analysis — it reads your code without running it and reasons about what type of value every variable, parameter, and return value can hold. If your code is logically inconsistent with those types (for example, calling a string method on a number), tsc reports an error at compile time, before a single line executes.
Once type checking passes, tsc performs type erasure: it deletes every type annotation, interface, and type alias, and emits ordinary JavaScript. This is the single most important mental model to hold onto: types exist only at compile time. There is no string or number keyword anywhere in the JavaScript that actually runs in Node or the browser. If you open the compiled output of a TypeScript file, it looks exactly like the JavaScript you’d have written by hand, just without the annotations.
TypeScript’s type system is also structural, not nominal. Two types are considered compatible if they have the same shape (the same properties and methods), regardless of what they’re named or whether one was explicitly declared to implement the other. This is different from languages like Java or C#, where a class must explicitly declare which interfaces it implements. In TypeScript, if it looks like a duck and quacks like a duck, it’s treated as a duck.
Finally, TypeScript relies heavily on type inference. You do not need to annotate every single variable. When you write let age = 36;, TypeScript infers that age has type number without you writing : number anywhere. Inference also flows through function calls, array literals, and object literals, which is why well-written TypeScript often looks almost identical to plain JavaScript — annotations are added mainly at boundaries (function parameters, exported values) where inference can’t guess your intent.
Setting Up and Syntax
To use TypeScript locally, you install it via npm and run the compiler against your .ts files:
npm install -g typescript
tsc --version
tsc app.ts # compiles app.ts to app.js
In a real project you’ll usually add TypeScript as a dev dependency and configure it with a tsconfig.json file, which tells tsc which files to compile and which compiler options to use:
npm install --save-dev typescript
npx tsc --init # generates tsconfig.json
The general syntax for adding a type annotation is a colon followed by the type, placed after the identifier you’re annotating:
let variableName: Type = value;
function fnName(param: ParamType): ReturnType { }
interface Name { property: Type; optionalProp?: Type; }
| Piece | Meaning |
|---|---|
: Type after a variable |
Declares the variable’s allowed type; assigning any other type is a compile error. |
: ParamType on a parameter |
Every call to the function must pass an argument matching (or assignable to) that type. |
: ReturnType after the parameter list |
The function body must return a value matching this type on every code path. |
property?: Type |
Marks a property as optional — it may be omitted from an object of that shape. |
interface / type |
Names a reusable object shape or type expression so it can be referenced elsewhere. |
Common important compiler flags, usually set in tsconfig.json, include strict (turns on the full set of strictness checks, including disallowing implicit any and requiring null checks), target (which JavaScript version to emit, e.g. es2020), and noEmitOnError (refuses to output JavaScript if there are type errors). Always enable strict — it is what makes TypeScript actually catch bugs instead of just documenting intent.
Examples
Example 1: Basic type annotations
let username: string = "Ada";
let age: number = 36;
let isAdmin: boolean = true;
function greet(name: string, times: number): string {
return `Hello, ${name}! `.repeat(times);
}
console.log(greet(username, 2));
console.log(`${username} is ${age} years old. Admin: ${isAdmin}`);
Output:
Hello, Ada! Hello, Ada!
Ada is 36 years old. Admin: true
Each variable is annotated with its type using the : Type syntax, and the greet function declares that both of its parameters and its return value must be specific types. If you tried to call greet(username, "twice"), tsc would reject it immediately, because "twice" is a string, not a number. Note that at runtime, none of the : string/: number/: boolean text exists — it’s erased, and the compiled JavaScript is indistinguishable from code you’d write by hand.
Example 2: Interfaces and optional properties
interface User {
id: number;
name: string;
email?: string;
}
function describeUser(user: User): string {
const emailPart = user.email ? ` (${user.email})` : "";
return `User #${user.id}: ${user.name}${emailPart}`;
}
const alice: User = { id: 1, name: "Alice" };
const bob: User = { id: 2, name: "Bob", email: "bob@example.com" };
console.log(describeUser(alice));
console.log(describeUser(bob));
Output:
User #1: Alice
User #2: Bob (bob@example.com)
The interface User declaration describes the shape any object must have to count as a User: a required id and name, and an optional email (marked with ?). Because TypeScript uses structural typing, alice and bob don’t need to say implements User anywhere — they simply satisfy the shape. Inside describeUser, TypeScript knows user.email might be undefined, so it forces you to check it (via the ternary) before using it in a template literal.
Example 3: Union types with arrays and objects
type Status = "pending" | "shipped" | "delivered";
interface Order {
id: number;
status: Status;
total: number;
}
function summarize(orders: Order[]): string {
const totalRevenue = orders.reduce((sum, order) => sum + order.total, 0);
const shipped = orders.filter((order) => order.status !== "pending").length;
return `Orders: ${orders.length}, Shipped or delivered: ${shipped}, Revenue: $${totalRevenue.toFixed(2)}`;
}
const orders: Order[] = [
{ id: 1, status: "pending", total: 49.99 },
{ id: 2, status: "shipped", total: 19.99 },
{ id: 3, status: "delivered", total: 99.5 },
];
console.log(summarize(orders));
Output:
Orders: 3, Shipped or delivered: 2, Revenue: $169.48
This is closer to real-world code. Status is a union of string literal types — the only valid values for a variable of type Status are exactly the strings "pending", "shipped", or "delivered". Assigning "cancelled" anywhere a Status is expected would be a compile error, which is far stricter (and safer) than a plain JavaScript string. The Order[] annotation says “an array of objects shaped like Order“, and TypeScript checks every element of the array literal against that shape.
Under the Hood: What tsc Actually Does
When you run tsc on a file, it moves through several phases:
- Parsing: the source text is turned into an Abstract Syntax Tree (AST), the same kind of structure a JavaScript-only parser would build, but extended to understand type annotations, interfaces, and generics.
- Binding: the compiler walks the AST and builds a symbol table, linking every identifier (variable, function, type name) to where it was declared, so it knows what
Userorordersrefers to at any point in the file. - Type checking: this is where most of the work happens. The checker computes (or uses your annotated) type for every expression, and verifies that every assignment, function call, and property access is compatible with the types involved. This is also where inference happens — if you didn’t write a type, the checker computes the most specific type it can from context.
- Emit: if there are no fatal errors (or if
noEmitOnErroris off), the compiler strips all type syntax and writes out plain JavaScript targeting whatevertargetyou configured (e.g.es2020), optionally alongside a source map so debuggers can map the emitted JS back to your original.tslines.
The critical takeaway: type checking and code generation are separate steps, and by default tsc will still emit JavaScript even if there are type errors (unless you set noEmitOnError: true). The emitted JavaScript itself contains zero type information — a Status union type becomes nothing at all in the output; it only ever existed to constrain what you were allowed to write.
Common Mistakes
Mistake 1: Using a type as if it were a runtime value
Because types are erased, you can’t use an interface with instanceof, or expect a type name to exist as a value:
interface Animal {
species: string;
}
function isAnimal(value: unknown): boolean {
return value instanceof Animal;
}
tsc reports: “‘Animal’ only refers to a type, but is being used as a value here.” An interface has no runtime representation at all — instanceof needs an actual constructor function, and Animal compiles away to nothing. The fix is to write a type guard that checks the object’s actual shape at runtime, or use a class instead of an interface if you specifically need instanceof checks:
interface Animal {
species: string;
}
function isAnimal(value: unknown): value is Animal {
return typeof value === "object" && value !== null && "species" in value;
}
const maybeAnimal: unknown = { species: "cat" };
console.log(isAnimal(maybeAnimal));
Output:
true
Mistake 2: Assigning a value of the wrong type
let count: number = "5";
tsc reports: “Type ‘string’ is not assignable to type ‘number’.” This is the most basic kind of type error, but it’s easy to trip into when converting values from external sources (form inputs, JSON, environment variables) that are always strings. The fix is to actually convert the value to the declared type before assigning it:
let count: number = 5;
count = Number("5");
console.log(count);
Output:
5
Note that Number("5") returns a real number at runtime; TypeScript is only checking that the value’s declared type matches, it can’t verify that a string like "5" or a garbage string like "abc" converts sensibly — that’s still your responsibility.
Best Practices
- Always enable
"strict": trueintsconfig.jsonfrom day one — retrofitting strict mode onto a large loose codebase is much more painful than starting with it. - Let inference do the work for local variables (
let total = 0;) and reserve explicit annotations for function parameters, return types, and exported/public APIs where inference has nothing to go on. - Prefer
interfacefor object shapes that might be extended, andtypefor unions, intersections, and aliases of primitives or tuples. - Avoid
any— it silently disables type checking for that value. Useunknownfor values of uncertain type, and narrow them with type guards before use. - Run
tsc --noEmitas a standalone check in CI, separate from your bundler, so type errors fail the build even if your bundler happens to ignore them. - Remember types vanish at runtime — never rely on a type name, interface, or generic parameter to make a runtime decision; check actual data shape or use a class with
instanceofinstead.
Practice Exercises
- Write a
Bookinterface withtitle(string),author(string),pages(number), and an optionalisbn(string). Write a functiondescribeBook(book: Book): stringthat returns a formatted sentence, including the ISBN only when it’s present. - Create a union type
Directionwith the literal values"north","south","east", and"west". Write a functionopposite(direction: Direction): Directionthat returns the opposite direction, and call it with a value that is not one of the four literals to see the errortscproduces. - Set up a
tsconfig.jsonwithstrictenabled andnoEmitOnErrorenabled, then deliberately introduce a type mismatch (like assigning anumberto astringvariable) and confirm that runningtscproduces no JavaScript output at all.
Summary
- TypeScript is JavaScript plus a static type system; the
tsccompiler checks types and then erases them, emitting plain JavaScript. - Types exist only at compile time — the emitted JavaScript has no trace of annotations, interfaces, or type aliases.
- TypeScript uses structural typing: an object satisfies a type if its shape matches, regardless of name or explicit declaration.
- Type inference means you don’t need to annotate everything; annotate mainly at function boundaries and public APIs.
strictmode is what makes TypeScript actually catch real bugs, including implicitanyand missing null checks.- Never rely on a type as a runtime value (e.g.
instanceof SomeInterface) — use type guards or classes instead.
