DEV Community

Cover image for TypeScript Fundamentals, Part 1: Describing Your Data
Grant Riordan
Grant Riordan

Posted on

TypeScript Fundamentals, Part 1: Describing Your Data

TypeScript Fundamentals, Part 1: Describing Your Data

Welcome to my 3 part series on learning the fundamentals of Typescript.

📚 TypeScript Fundamentals series

Each part stands on its own. Read them in order, or jump straight to the one you need.

So you've decided to learn TypeScript. Maybe you've been writing JavaScript and want to level up your code, or maybe you've heard it's hugely popular and want to stay ahead of the trends 🤷‍♂️

Either way, this series walks through the key fundamentals of the language. This first part is about the most basic job TypeScript does: describing the shape of your data.

Who is this part for?
Anyone who knows basic JavaScript. No TypeScript experience needed.

What is TypeScript?

TypeScript is a superset of JavaScript: every valid JavaScript program is also a valid TypeScript program, and TypeScript adds a type system on top. On the surface it can look complicated, but in essence it's just JS with some extra syntax. In return you get:

  • Static typing and type annotations
  • Interfaces and type aliases
  • Advanced tooling and IntelliSense
  • Generics, enums and utility types (covered in Part 3)
  • Safer refactoring

Interfaces & Types

One of the biggest upgrades TypeScript brings is its type system. If you're coming from JavaScript, you'll know it's a dynamically typed language: a variable can hold a string one moment and a number the next.

Interfaces and types give your code structure and enforce static typing. Your objects and variables are assigned strict types, and if your code doesn't abide by them, it won't compile.

Let's look at some examples.

Interfaces

interface Animal {
    name: string;
    age: number;
    makeSound(): void;
}

class Dog implements Animal {
    name: string;
    age: number;

    constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
    }

    makeSound(): void {
        console.log("Woof");
    }
}

class Cat implements Animal {
    name: string;
    age: number;

    constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
    }

    makeSound(): void {
        console.log("Meow");
    }
}
Enter fullscreen mode Exit fullscreen mode

The code above defines an interface called Animal, which is implemented by the Dog and Cat classes. If you're familiar with other typed or object-oriented languages, this concept will feel familiar. If not, in its simplest form it means that Dog and Cat must implement the properties and methods defined in Animal. If they don't, your editor will show errors, and the TypeScript compiler will complain when you try to build the application.

class not implementing interface properties error screenshot

Interfaces can also be used to type plain objects:

interface HttpResponse {
    statusCode: number;
    data: string;
}

function handleHttpResponse(response: HttpResponse) {
    console.log(response.statusCode);
    console.log(response.data);
}
Enter fullscreen mode Exit fullscreen mode

The handleHttpResponse() function accepts an HttpResponse object. Passing anything that doesn't match that shape won't satisfy the compiler.

To declare that a variable has a particular type, use a type annotation (a colon followed by the type):

const response: HttpResponse = {
    statusCode: 200,
    data: "The API request was successful"
};
Enter fullscreen mode Exit fullscreen mode

Optional and readonly properties

Two small modifiers you'll use constantly. ? marks a property as optional, and readonly stops it being reassigned after creation:

interface Profile {
    readonly id: number;   // can be set once, never changed
    name: string;
    nickname?: string;     // may be missing
}

const profile: Profile = { id: 1, name: "Sam" };  // ✅ nickname is optional
profile.name = "Samantha";                         // ✅
profile.id = 2;                                    // ❌ cannot assign to 'id', it is read-only
Enter fullscreen mode Exit fullscreen mode

Types

Type aliases are very similar to interfaces: they also describe the shape of an object. Here's the same Animal shape as a type:

type Animal = {
    name: string;
    age: number;
    makeSound: () => void;
};

const dog: Animal = {
    name: "Buddy",
    age: 5,
    makeSound: () => {
        console.log("Woof");
    }
};

const cat: Animal = {
    name: "Whiskers",
    age: 3,
    makeSound: () => {
        console.log("Meow");
    }
};
Enter fullscreen mode Exit fullscreen mode

"Wait, that looks identical to an interface," I hear you say. You're right: for object shapes the two overlap significantly. So where do they differ?

A Quick Detour: Structural Typing

Before we compare them, there's one idea that explains a lot of TypeScript's behaviour. TypeScript uses structural typing: it checks the shape of a value, not the name of the type it was declared with. If a value has the right properties, it fits.

interface Point {
    x: number;
    y: number;
}

function logPoint(p: Point) {
    console.log(p.x, p.y);
}

const pixel = { x: 10, y: 20, color: "red" };
logPoint(pixel); // ✅ pixel was never declared as a Point, but it has the right shape
Enter fullscreen mode Exit fullscreen mode

Nothing had to say implements Point. This is different from languages like Java or C#, where types are matched by name. It's also why interfaces and type aliases are so interchangeable: both just describe a shape.

One guard rail: if you pass a fresh object literal directly, TypeScript checks for extra properties too, because that's usually a typo:

logPoint({ x: 1, y: 2, color: "red" }); // ❌ 'color' does not exist in type 'Point'
Enter fullscreen mode Exit fullscreen mode

Where Interfaces and Types Differ

Declaration merging

Interfaces with the same name in the same scope merge automatically. Type aliases can't be redeclared.

interface User { name: string }
interface User { age: number }   // merged: { name: string; age: number }

type Account = { id: string }
type Account = { email: string } // error: duplicate identifier
Enter fullscreen mode Exit fullscreen mode

This is why library authors favour interfaces: consumers can augment them. A common example is extending the Request type from the popular Express library.

What they can describe

Interfaces can only describe object shapes (including callable and constructable ones). Type aliases can name almost anything:

type ID = string | number                    // union
type Pair = [string, number]                 // tuple
type Status = 'idle' | 'loading'             // literal union
type Keys = keyof User                       // computed from another type
type Partial2<T> = { [K in keyof T]?: T[K] } // mapped type
Enter fullscreen mode Exit fullscreen mode

Extending

Interfaces use extends; types use intersections (&):

// Interface
interface Admin extends User { role: string }

// Type alias
type SuperAdmin = User & { role: string }
Enter fullscreen mode Exit fullscreen mode

The two look similar, but they behave differently when properties conflict. With an interface, a conflicting property type between the base and the extension produces a clear error at the declaration. With an intersection, the conflicting property silently becomes never, and you only find out when you try to use it.

Interfaces also tend to give nicer error messages and are cached better by the compiler, which can matter in very large codebases.

Classes

A class can implement either one, as long as it describes an object shape (not a union).

Which Should You Use?

For consistency, pick one default and write down the exceptions. There are two common conventions:

1. Interface for object shapes, type for everything else.
This is the TypeScript team's long-standing guidance and what many style guides recommend. You get the compiler benefits of interfaces, while types handle unions, tuples and utility types, which interfaces can't express anyway.

2. Type everywhere.
A simpler rule with no judgement calls, and you never hit a case that can't be expressed as an interface. The downside is giving up declaration merging, which only matters if you're publishing a library or augmenting third-party types.

If you're building an application rather than a library, either works fine. I'd lean towards option 1 if you want to follow common community convention, and option 2 if you value a single rule that nobody has to think about.

Whichever you choose, enforce it with tooling rather than code review.

The ESLint rule @typescript-eslint/consistent-type-definitions can flag (and auto-fix) the other style.

Unions, Literal Types & Tuples

Unions

A union type describes a value that can be one of several possible types. Unions are written with the | operator:

type ID = string | number

const a: ID = "abc-123";  // ✅
const b: ID = 42;         // ✅
const c: ID = true;       // ❌ boolean isn't part of the union
Enter fullscreen mode Exit fullscreen mode

Literal types

A literal type is a type whose only valid value is one specific value, such as 'idle' or 404. Combine several literals in a union and you get a precise, self-documenting set of allowed values:

type Status = 'idle' | 'loading' | 'success' | 'error'
Enter fullscreen mode Exit fullscreen mode

Discriminated unions

Literal types really shine when they're used to tell the members of a union apart. Take the state of a page that loads some users from an API:

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

type RequestState =
    | { status: 'idle' }
    | { status: 'loading' }
    | { status: 'success'; data: User[] }
    | { status: 'error'; error: Error; retryable: boolean }
Enter fullscreen mode Exit fullscreen mode

RequestState is a union of four different object shapes. The first two have identical properties, but they aren't the same type, because the status property is a literal type: one allows only 'idle' and the other only 'loading'. That shared literal property is called the discriminant, and it's what lets TypeScript work out which variant you're dealing with.

It also means each variant must provide exactly the fields that make sense for it. A 'success' state must include data, and an 'error' state must include error and retryable. You can't have data on an error, or an error while loading.

Here's how you'd use it:

function render(state: RequestState): string {
    switch (state.status) {
        case 'idle':
            return 'Click to load';
        case 'loading':
            return 'Loading...';
        case 'success':
            return `Loaded ${state.data.length} users`;
        case 'error':
            return state.retryable
                ? `Failed: ${state.error.message}. Retrying...`
                : `Failed: ${state.error.message}`;
    }
}
Enter fullscreen mode Exit fullscreen mode

Inside each case, TypeScript narrows the type of state to the matching variant. That's why state.data is available in the 'success' branch, while trying to read it in the 'error' branch would be a compile error. (Narrowing has a few more tricks, which we cover in Part 2.)

Tuples

A tuple is an array with a fixed length where each position has its own type. Use one when you have a small, ordered group of values that mean different things:

type Coordinates = [number, number]

const london: Coordinates = [51.5072, -0.1276]

const [lat, lng] = london  // destructuring works as you'd expect
Enter fullscreen mode Exit fullscreen mode

Unlike a regular number[], TypeScript knows exactly what's at each position, and it will complain if you get the length or the order wrong:

const bad1: Coordinates = [51.5072]                // ❌ missing an element
const bad2: [string, number] = [200, "OK"]         // ❌ wrong order
Enter fullscreen mode Exit fullscreen mode

You can also label the positions to make them self-documenting:

type HttpResult = [statusCode: number, body: string]
Enter fullscreen mode Exit fullscreen mode

Tuples will look familiar if you've used React: the useState hook returns a [value, setter] pair, which is a tuple.

Wrapping Up

  • Interfaces and type aliases both describe object shapes, and for most everyday code they're interchangeable.
  • TypeScript is structurally typed: what matters is the shape of a value, not the name of its type.
  • Interfaces support declaration merging and give clear errors when extending.
  • Types can also describe unions, tuples and mapped types.
  • Discriminated unions let you model state so that impossible combinations can't be represented.
  • Pick one convention for your project, write it down, and enforce it with a linter.

Continue the Series

Top comments (0)