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 array

The 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}'))); // -> true

The 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 nothing

Annotate 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" check

TypeScript 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.

SituationReach forWhy
JSON.parse resultunknownthe server can send anything, so prove the shape first
a catch bindingunknownanyone can throw anything, including a string
a value from localStorageunknowna user or an old version of your app wrote it
a third-party library with no typesunknown at the edgenarrow once, in one file, then stay typed
you are in a hurryany, with a TODOhonest and greppable, unlike a wrong type
a genuinely generic containera 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

  1. Add the compiler, check nothing Install TypeScript and add a config with allowJs and checkJs off. Zero errors on day one, which means the build stays green while you learn.
  2. Get value with zero syntax change Add // @ts-check to the top of one JavaScript file, and describe shapes with JSDoc. Full checking, still plain JS, no build step needed.
  3. 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.
  4. 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.
  5. Turn on strict last, per file Enable strict and 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 type do that interface cannot?
Declare a union such as string | number — Unions, tuples and computed types need type. Both can describe object shapes and both can be extended (extends for interfaces, & for types). The reverse asymmetry is declaration merging: re-declaring an interface merges it, which is how you augment a library's types.
Why prefer unknown over any for a JSON.parse result?
unknown blocks every access until you narrow it, so the check cannot be forgotten — any opts out of checking: input.user.name compiles and crashes. unknown accepts any value but permits no operations until you narrow it with typeof, in, Array.isArray or 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 first here?
x is string | undefined — T is inferred as string from the argument, so the return type becomes string | undefined. The | undefined is 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 any when the value is genuinely unknown. unknown gives 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 as with a comment explaining it.
  • Blocking a migration on a repo-wide zero-error day. Convert leaves first, keep strict for 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.
  • interface and type overlap for object shapes; unions, tuples and computed types need type.
  • Typing is structural: the right shape fits, no matter what the value is named or which class made it.
  • unknown is any with the checker still on. Narrow it once at the edge, then stay typed inside.
  • Migrate gradually: @ts-check with JSDoc, then leaf files, then boundaries, then strict.