Ayan

WritingTypeScript Primer for JS Developers

5 min read

TypeScript Primer for JS Developers

A fast, practical introduction to TypeScript for people who already write JavaScript: the mental model, the types you will use daily, and the mistakes that cause most confusion.

If you already write JavaScript, you know most of TypeScript. The language is the same. What changes is that you describe the shape of your data, and a compiler checks that you use it correctly before the code runs. This primer covers the mental model first, then the features you will use daily, then the mistakes that trip people up.

The mental model

Think of TypeScript as a spell checker for data shapes. A spell checker does not change what you write, and it does not exist at the moment someone reads it. It only flags mistakes while you are writing.

That is exactly how TypeScript behaves:

Keep the last two points in mind. Most TypeScript bugs in production come from forgetting that types vanish at runtime.

Setup in two minutes

npm install -D typescript
npx tsc --init

In tsconfig.json, turn on strict mode and leave it on:

{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "module": "ESNext"
  }
}

Trade-off: strict produces more errors on day one. Without it, TypeScript allows enough loose behavior that you lose most of the benefit. Start strict, because loosening later is easier than tightening a large codebase.

Basic types and inference

You annotate variables with a colon and a type:

const name: string = "Asha";
const age: number = 24;
const isActive: boolean = true;
const scores: number[] = [90, 85, 77];

You rarely need to write these. TypeScript infers types from values:

const city = "Pune";      // inferred as "Pune"
let count = 0;            // inferred as number
count = "five";           // Error: string is not assignable to number

Rule of thumb: annotate function parameters and public return types. Let inference handle local variables.

Describing objects: type and interface

Both describe the shape of an object.

interface User {
  id: number;
  name: string;
  email?: string; // optional
}

type Product = {
  id: number;
  title: string;
  price: number;
};

The practical difference is small. interface can be extended and merged across declarations. type can also describe unions and primitives. Pick one convention per project and stay consistent. A reasonable default: interface for object shapes, type for everything else.

Union types and narrowing

A union says a value can be one of several types:

type Id = string | number;

function printId(id: Id) {
  if (typeof id === "string") {
    console.log(id.toUpperCase()); // id is string here
  } else {
    console.log(id.toFixed(0));    // id is number here
  }
}

The compiler tracks the type through your if checks. This is called narrowing, and it is the feature that makes TypeScript feel intelligent rather than bureaucratic.

Literal types

You can restrict a value to exact options:

type Status = "idle" | "loading" | "success" | "error";

let status: Status = "loading";
status = "done"; // Error

This replaces many bugs caused by typos in strings.

Discriminated unions

Combine literal types with objects for safe state modelling:

type Result =
  | { status: "success"; data: string[] }
  | { status: "error"; message: string };

function handle(result: Result) {
  if (result.status === "success") {
    console.log(result.data);    // data exists here
  } else {
    console.log(result.message); // message exists here
  }
}

Compare this with a single object that has optional data and optional message. That version allows impossible states, such as both being present. The union makes impossible states unrepresentable.

Functions

function add(a: number, b: number): number {
  return a + b;
}

const greet = (name: string, greeting = "Hello"): string =>
  `${greeting}, ${name}`;

For callbacks, describe the function type:

type Predicate<T> = (item: T) => boolean;

Generics

Generics let you write reusable code that stays type safe. Picture a labeled storage box: the box works for any content, but once you label it “books”, only books should go in.

function first<T>(items: T[]): T | undefined {
  return items[0];
}

const n = first([1, 2, 3]);       // number | undefined
const s = first(["a", "b"]);      // string | undefined

You will mostly consume generics rather than write them: Array<string>, Promise<User>, Record<string, number>.

Failure mode: over-engineering. If a generic needs three type parameters and a conditional type, a simpler design usually exists.

unknown versus any

any turns off type checking for a value. It spreads: anything derived from an any is also unchecked.

unknown means “I do not know the type yet, so prove it before use”:

function parse(input: unknown) {
  if (typeof input === "string") {
    return input.trim(); // safe, narrowed to string
  }
  throw new Error("Expected string");
}

Use unknown for data from outside your program. Reserve any for rare, deliberate escapes, and treat each one as debt.

Useful utility types

TypeScript ships helpers that transform existing types:

interface User {
  id: number;
  name: string;
  email: string;
}

type UserPreview = Pick<User, "id" | "name">;
type UserUpdate = Partial<User>;           // all fields optional
type PublicUser = Omit<User, "email">;
type UserMap = Record<number, User>;
type ReadonlyUser = Readonly<User>;

These keep one source of truth. Change User once and the derived types follow.

Null handling

With strict on, null and undefined are not silently allowed everywhere. The compiler forces you to deal with them:

function getLength(text?: string) {
  return text?.length ?? 0; // optional chaining + nullish coalescing
}

Avoid the non-null assertion operator (value!). It silences the compiler without making the code safer. If the value is null at runtime, you get the same crash you had in JavaScript.

Types do not validate runtime data

This is the most important failure mode in the whole article.

const res = await fetch("/api/user");
const user = (await res.json()) as User; // a claim, not a check

The as User cast tells the compiler to trust you. If the server returns a different shape, nothing stops it, and the bug appears later, far from its cause.

At system boundaries (API responses, form input, environment variables, JSON.parse), validate with a runtime schema library such as Zod, then derive the type from the schema. That gives you one definition that checks data at runtime and types it at compile time.

Common mistakes

  1. Using any to make errors disappear. The error was information. Fix the type or use unknown.
  2. Casting with as instead of narrowing. Casts bypass checks. Narrowing proves the claim.
  3. Annotating everything. Redundant annotations add noise and drift out of date.
  4. Treating types as validation. They are erased before the code runs.
  5. Disabling strict to move faster. You trade a small amount of friction now for a large amount of ambiguity later.

A sensible learning path

  1. Convert one small JavaScript file to .ts with strict on.
  2. Type your function parameters and let inference do the rest.
  3. Model one real piece of state with a discriminated union.
  4. Add Zod validation to one API call.
  5. Read the compiler errors slowly. They are the best teacher.

Wrap-up

TypeScript is JavaScript plus a checker that understands your data shapes. Learn inference, unions, narrowing, and a few utility types, and you cover most daily use. Remember that types disappear at runtime, so validate anything that crosses a boundary. Start strict, avoid any, and let the compiler argue with you early rather than users arguing with you later.

Back to all writing