TypeScript in One Lesson
Mental model: TypeScript is a spell-checker for the shapes of your values. It runs before your program does, deletes every annotation, and ships plain JavaScript. Nothing it says exists at runtime.
Level: beginner · about 20 minutes
You have already been thinking in types for seventeen modules. Every time you asked "is this an array or a single item", "can this be null", "does this function return a promise", you were doing type checking in your head. TypeScript writes those answers down and has a tool check them for you, every keystroke, across every file at once.
Types are documentation that cannot go stale
// JavaScript, with a hopeful comment
/** @param items array of { price, qty } @returns number */
function total(items) {
return items.reduce((sum, i) => sum + i.price * i.qty, 0);
}
// TypeScript: the same information, checked
type LineItem = { price: number; qty: number };
function total(items: LineItem[]): number {
return items.reduce((sum, i) => sum + i.price * i.qty, 0);
}
total([{ price: 10, qty: 2 }]); // fine
total([{ price: '10', qty: 2 }]); // error: string is not assignable to number
total({ price: 10, qty: 2 }); // error: expected an arrayThe same function, twice. The comment lies as soon as someone edits the code. The types cannot.
// TypeScript said: function total(items: { price: number }[]): number
// After compiling, every annotation is deleted. This is what actually runs:
function total(items) {
return items.reduce((sum, i) => sum + i.price, 0);
}
const fromApi = JSON.parse('[{"price":"10"},{"price":"5"}]');
console.log(total(fromApi));Types are erased at compile time, so nothing checks the JSON the server sent. 0 + "10" concatenates to "010", then "010" + "5" gives "0105". This is the single most important thing to know about TypeScript: it checks the code you wrote, never the data that arrives at runtime. Anything crossing a boundary (network, storage, form input) still needs a real check.
function isUser(value) {
return (
typeof value === 'object' &&
value !== null &&
typeof value.name === 'string' &&
Number.isInteger(value.age)
);
}
console.log(isUser({ name: 'Ada', age: 36 })); // -> true
console.log(isUser({ name: 'Ada', age: '36' })); // -> false a string is not an integer
console.log(isUser(null)); // -> false typeof null is 'object'
console.log(isUser(JSON.parse('{"name":"Ada","age":36}'))); // -> trueThe runtime half of the job, in plain JavaScript. A type predicate you can actually execute.
Inference does most of the work
let count = 0; // inferred: number
const name = 'Ada'; // inferred: 'Ada' (a literal type, because it is const)
const tags = ['a', 'b']; // inferred: string[]
const prices = [10, 20];
const doubled = prices.map((p) => p * 2); // inferred: number[]
// Annotate parameters, because nothing else can tell the compiler what you accept.
function greet(user: { name: string }) {
return `Hello, ${user.name}`; // return type inferred: string
}
// Over-annotating is noise, not safety:
const total: number = 10 + 5; // the ": number" adds nothingAnnotate the inputs and the boundaries. Let the compiler work out the rest.
interface versus type
interface
interface User {
name: string;
age: number;
}
interface Admin extends User {
permissions: string[];
}
// re-declaring MERGES (declaration
// merging), which is how libraries
// let you extend their types
interface User {
email: string;
}
type
type User = {
name: string;
age: number;
};
type Admin = User & {
permissions: string[];
};
// types can do things interfaces
// cannot:
type Id = string | number;
type Keys = keyof User;
type Pair = [number, number];For object shapes they are nearly interchangeable, so a team convention matters more than the choice. A common one: interface for object shapes you might extend, type for unions, tuples, function types and anything computed. If in doubt, use type, since it covers every case.
Structural typing: shape, not name
type Point = { x: number; y: number };
function distance(p: Point) {
return Math.hypot(p.x, p.y);
}
class Vector {
constructor(public x: number, public y: number) {}
}
distance(new Vector(3, 4)); // fine: it has x and y
distance({ x: 3, y: 4, colour: 'red' }); // error only for a fresh object literal
const extra = { x: 3, y: 4, colour: 'red' };
distance(extra); // fine: a variable is not an "excess property" checkTypeScript checks members, not names. This is the duck typing you already use, only verified: no implements, no inheritance, no ceremony.
unknown versus any
function handleAny(input: any) {
return input.user.name.toUpperCase(); // compiles, and crashes at runtime
}
function handleUnknown(input: unknown) {
// return input.user; // error: 'input' is of type 'unknown'
if (typeof input === 'object' && input !== null && 'user' in input) {
const user = (input as { user: { name?: string } }).user;
return user.name?.toUpperCase() ?? 'anonymous';
}
return 'anonymous';
}any switches the checker off. unknown keeps it on and makes you prove the shape.
| Situation | Reach for | Why |
|---|---|---|
JSON.parse result | unknown | the server can send anything, so prove the shape first |
a catch binding | unknown | anyone can throw anything, including a string |
a value from localStorage | unknown | a user or an old version of your app wrote it |
| a third-party library with no types | unknown at the edge | narrow once, in one file, then stay typed |
| you are in a hurry | any, with a TODO | honest and greppable, unlike a wrong type |
| a genuinely generic container | a generic <T> | any forgets, T remembers |
Generics, without the theory
function first<T>(items: T[]): T | undefined {
return items[0];
}
first([1, 2, 3]); // T is number -> number | undefined
first(['a', 'b']); // T is string -> string | undefined
// Without the generic, you lose the link:
function firstAny(items: any[]): any { return items[0]; }
firstAny([1, 2]).toUpperCase(); // compiles, then explodes
// Constrain when you need a member:
function byId<T extends { id: string }>(items: T[], id: string): T | undefined {
return items.find((item) => item.id === id);
}A generic is a parameter for a type instead of a value. Read <T> as "whatever type came in, that is the type that comes out".
A migration path that does not stall
- Add the compiler, check nothing Install TypeScript and add a config with
allowJsandcheckJsoff. Zero errors on day one, which means the build stays green while you learn. - Get value with zero syntax change Add
// @ts-checkto the top of one JavaScript file, and describe shapes with JSDoc. Full checking, still plain JS, no build step needed. - Rename leaves first Convert files with no imports of your own code (
utils/,lib/) to.ts. They are pure functions, so the types are obvious and nothing depends on your guesses. - Type the boundaries, not the middle Describe what enters and leaves the program: API responses, form values, storage. Once the edges are honest, inference types most of the interior for you.
- Turn on
strictlast, per file Enablestrictand fix the flood one file at a time, or keep it on for new files only. Do not block the migration on a repo-wide zero-error day.
Try it yourself
A structural type check at runtime
function matches(value, shape) {
if (typeof value !== 'object' || value === null) return false;
return Object.entries(shape).every(([key, expected]) => {
const actual = Array.isArray(value[key]) ? 'array' : typeof value[key];
return actual === expected;
});
}
const User = { name: 'string', age: 'number', tags: 'array' };
console.log(matches({ name: 'Ada', age: 36, tags: [] }, User)); // -> true
console.log(matches({ name: 'Ada', age: '36', tags: [] }, User)); // -> false
console.log(matches({ name: 'Ada', age: 36, tags: [], x: 1 }, User)); // -> true
console.log(matches(null, User)); // -> false
Add support for optional keys written as email? and for arrays written as ['string']. Then make the error message say which key failed, not just true or false.
Narrowing an unknown value
function describe(value) {
if (value === null) return 'null';
if (Array.isArray(value)) return `array of ${value.length}`;
if (value instanceof Date) return 'date';
if (typeof value === 'object') return 'object';
return typeof value;
}
for (const v of [null, [1, 2], new Date(0), { a: 1 }, 'hi', 42, undefined, Symbol('s')]) {
console.log(describe(v));
}
// -> null / array of 2 / date / object / string / number / undefined / symbol
Add a branch for Map and one for a plain object, then try feeding it a class instance. Which branch catches it, and is that what you wanted?
Exercises
Prove the shape at the boundary
Data arriving from a server is unknown, whatever your types claim. Write toUser(input) which returns a new { name, age } object when input is an object with a non-empty string name (trimmed in the result) and a non-negative integer age. Throw a TypeError for anything else. Extra properties are ignored, not copied.
Check yourself
- A typed function says it takes
number. At runtime it receives a string from an API. What happens? - Nothing checks it, and the string flows through as if it were a number — Annotations are erased during compilation, so there is no runtime check at all. TypeScript verified the code you wrote, not the data that arrived. That is why every boundary (fetch, storage, form, environment) needs a real runtime check, whether hand-written or from a validation library.
- Which of these can
typedo thatinterfacecannot? - Declare a union such as
string | number— Unions, tuples and computed types needtype. Both can describe object shapes and both can be extended (extendsfor interfaces,&for types). The reverse asymmetry is declaration merging: re-declaring aninterfacemerges it, which is how you augment a library's types. - Why prefer
unknownoveranyfor aJSON.parseresult? unknownblocks every access until you narrow it, so the check cannot be forgotten —anyopts out of checking:input.user.namecompiles and crashes.unknownaccepts any value but permits no operations until you narrow it withtypeof,in,Array.isArrayor a predicate. Neither does anything at runtime, so the parsing itself is still unvalidated until you write the check.- What does the compiler infer for
firsthere? xisstring | undefined—Tis inferred asstringfrom the argument, so the return type becomesstring | undefined. The| undefinedis the honest part: an empty array would give you nothing, and the compiler will now make you handle that case before calling.toUpperCase().
Common mistakes
- Believing types protect you at runtime. They are deleted at compile time, so every boundary still needs a real check.
- Reaching for
anywhen the value is genuinely unknown.unknowngives the same freedom with the check still on. - Annotating everything, including obvious locals. Annotate parameters and boundaries, let inference do the rest.
- Fighting the compiler for an afternoon instead of writing one honest
aswith a comment explaining it. - Blocking a migration on a repo-wide zero-error day. Convert leaves first, keep
strictfor new files. - Assuming a library's published types are correct. They are hand-written and can be wrong.
Takeaways
- TypeScript is a compile-time checker. Every annotation is erased, so nothing it says exists at runtime.
- Annotate parameters and boundaries. Inference covers locals and return types for free.
interfaceandtypeoverlap for object shapes; unions, tuples and computed types needtype.- Typing is structural: the right shape fits, no matter what the value is named or which class made it.
unknownisanywith the checker still on. Narrow it once at the edge, then stay typed inside.- Migrate gradually:
@ts-checkwith JSDoc, then leaf files, then boundaries, thenstrict.