The Essential Type System

Advanced · 18 min read · ▶ live playground · ✦ checkpoint

TypeScript feels much smaller once you know the few shapes you use every day. The essential type system is this: annotate the boundaries, let inference handle obvious local values, describe object and array shapes, model choices with unions, and narrow a value before using operations that only make sense for one case.

This lesson is a static tour inside the JavaScript course. For live compiler practice, error exploration, and larger typed exercises, use the full TypeScript course.

Annotations and inference

A type annotation is an explicit promise you write with :. You usually place annotations where values enter or leave a function, module, or API boundary.

function formatCents(cents: number): string {
  const dollars = cents / 100;
  return new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: "USD",
  }).format(dollars);
}
 
const receiptTotal = formatCents(2499);

cents: number says callers must pass a number. : string after the parameter list says the function returns a string. receiptTotal does not need an annotation because TypeScript can infer it from the return type of formatCents.

Inference means TypeScript figures out a type from the code you already wrote. You do not need to annotate every local variable. In fact, doing so often makes code noisier:

const itemCount = 3;
const subtotalCents = 2197;
const hasFreeShipping = subtotalCents >= 2000;
 
function shippingLabel(isFree: boolean): string {
  return isFree ? "Free shipping" : "Shipping calculated at checkout";
}
 
const label = shippingLabel(hasFreeShipping);

The useful rule is: annotate the boundary, infer the middle. Function parameters, return types on exported functions, and data coming from outside deserve clarity. Temporary values created from obvious expressions can usually be inferred.

When a caller breaks a promise, the message points at the mismatch:

Argument of type 'string' is not assignable to parameter of type 'number'.

That is the type system doing the job 20.1 introduced: catching a broken value promise before the program runs.

Objects and arrays

Objects are where TypeScript starts paying rent. A JavaScript object can have any shape at runtime; a TypeScript object type describes the shape your code expects.

type CartItem = {
  id: string;
  title: string;
  priceCents: number;
  inStock: boolean;
  tags: string[];
};
 
const cart: CartItem[] = [
  {
    id: "notebook",
    title: "Notebook",
    priceCents: 1299,
    inStock: true,
    tags: ["paper", "school"],
  },
  {
    id: "mug",
    title: "Coffee Mug",
    priceCents: 1599,
    inStock: false,
    tags: ["kitchen"],
  },
];
 
function availableTitles(items: CartItem[]): string[] {
  return items.filter((item) => item.inStock).map((item) => item.title);
}
 
const titles = availableTitles(cart);

Read CartItem[] as "an array of CartItem objects." string[] means an array of strings. Inside availableTitles, TypeScript knows every item has inStock, title, priceCents, and the rest of the declared shape.

That does not make arrays or objects immutable. It only describes what kind of values belong there. If you want to communicate "this property should not be reassigned", TypeScript gives you a separate keyword for that.

interface, type, optional, and readonly

You will see both interface and type in real TypeScript.

interface UserProfile {
  readonly id: string;
  name: string;
  nickname?: string;
}
 
type Role = "reader" | "editor" | "admin";
 
function profileLabel(profile: UserProfile, role: Role): string {
  const displayName = profile.nickname ?? profile.name;
  return `${displayName} (${role})`;
}
 
const user: UserProfile = {
  id: "user_123",
  name: "Mina",
};
 
const profileText = profileLabel(user, "editor");

For this taster, keep the distinction practical:

  • Use interface freely for object shapes, especially public object contracts.
  • Use type when you need to name something that is not just an object shape, such as a union of literal strings.
  • When a project already has a style, follow the local style.

nickname?: string means the property is optional. Code must handle the case where it is absent, which is why the example uses profile.nickname ?? profile.name.

readonly id: string means code should not reassign profile.id through this object. It is a compile-time guard, not a runtime freezer. JavaScript still runs as JavaScript after TypeScript is erased.

If code tries to reassign that property, TypeScript reports it:

Cannot assign to 'id' because it is a read-only property.

Unions and literal types

A union type uses | to say "one of these types." A literal type uses an exact value as a type.

In the Role example, "reader" | "editor" | "admin" means role is not any string. It is one of three exact strings. That is how TypeScript turns a loose JavaScript convention into a checked rule.

Unions can describe primitive choices:

type ProductId = string | number;
type SaveState = "idle" | "saving" | "saved" | "failed";
 
function saveButtonText(state: SaveState): string {
  if (state === "saving") {
    return "Saving...";
  }
 
  if (state === "saved") {
    return "Saved";
  }
 
  if (state === "failed") {
    return "Try again";
  }
 
  return "Save";
}
 
const productId: ProductId = "sku_42";
const buttonText = saveButtonText("idle");

The exact strings matter. A typo such as "succes" is not a valid SaveState. That is a whole state bug deleted from the program.

Narrowing

When a value has a union type, TypeScript will not let you use case-specific operations until you prove which case you have. Narrowing means using a runtime check that gives TypeScript better information for the code inside a branch.

typeof narrows primitive unions:

type SearchInput = string | string[];
 
function normalizeSearch(input: SearchInput): string[] {
  if (typeof input === "string") {
    return [input.trim()];
  }
 
  return input.map((term) => term.trim());
}
 
interface EmailMessage {
  kind: "email";
  email: string;
  subject: string;
}
 
interface SmsMessage {
  kind: "sms";
  phone: string;
  body: string;
}
 
type AppMessage = EmailMessage | SmsMessage;
 
function destination(message: AppMessage): string {
  if ("email" in message) {
    return message.email;
  }
 
  return message.phone;
}
 
function preview(message: AppMessage): string {
  switch (message.kind) {
    case "email":
      return `Email: ${message.subject}`;
    case "sms":
      return `SMS: ${message.body}`;
  }
}

Three narrowing patterns show up here:

  • typeof input === "string" proves the first branch has a string, so .trim() is safe.
  • "email" in message proves the message has an email property, so it is the email shape.
  • message.kind is a discriminant, a shared property with exact literal values. Switching on it narrows each branch to the matching object type.

Discriminated unions are one of TypeScript's best everyday patterns. Instead of asking "which random fields happen to exist?", each shape carries a small tag like kind: "email" or kind: "sms". That gives both humans and the checker an obvious way to split the cases.

The essential type system is now on the table: annotations, inference, object and array shapes, unions, literal values, optional and readonly properties, and narrowing. Next, you will learn how TypeScript keeps those ideas reusable with generics and how its built-in utility types reshape existing types without copying everything by hand.

Checkpoint

Answer all three to mark this lesson complete

+50 XP on completion