TypeScript Fundamentals, Part 1: Describing Your Data
Welcome to my 3 part series on learning the fundamentals of Typescript.
📚 TypeScript Fundamentals series
Part 1: Describing Your Data: interfaces, type aliases, unions, tuples (you are here)
Part 2: Writing Safer Code: inference, strict mode,
unknown, narrowingPart 3: Reusable Types: generics, utility types, enums vs literal unions
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");
}
}
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.
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);
}
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"
};
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
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");
}
};
"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
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'
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
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
Extending
Interfaces use extends; types use intersections (&):
// Interface
interface Admin extends User { role: string }
// Type alias
type SuperAdmin = User & { role: string }
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
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'
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 }
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}`;
}
}
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
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
You can also label the positions to make them self-documenting:
type HttpResult = [statusCode: number, body: string]
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
Part 2: Writing Safer Code: how type inference works, why
strictmode matters,anyvsunknown, and narrowing.Part 3: Reusable Types: generics, utility types like
PartialandPick, and enums vs literal unions.

Top comments (0)