TypeScript: Learn What Matters

If JavaScript works, why did we ever need anything else? That is the right question.
JavaScript will let you write a little script with little ceremony. Create a variable, pass to a function, change its value, and more.
But as soon as your codebase grows and you attempt to do anything more interesting, you start encountering edge cases.
Another developer reads a function you've written, but what did that function's author actually intend?
An API call that you've relied on returns a slightly different response. Someone renamed a field in the code, but not in all files
Your editor has no idea what the contents of an object are. So far, everything was okay. But one day, something will go wrong, and it will be challenging to track down the reason.
That is why we need TypeScript
TypeScript enhances JavaScript by adding static types on top of it. Through those, your editor can analyze what pieces of code are supposed to do, making the whole development process more pleasant and reliable.
The purpose of this article is to discuss the concepts of TypeScript that I believe are worth learning in the first place:
types, objects, unions, intersections, generics, the tsconfig.json, and how TypeScript differs from JavaScript.
Why TypeScript Exists
In JavaScript, you are not required to identify the type of a variable:
let value = 42;
value = 'hello';
Everything is valid JS.
That is not inherently bad;
JavaScript's primary advantage is its flexibility and easiness of use.
But how would you write a function that takes a user and renders them?
function createUser(user) {
return `${user.name} (${user.email})`;
}
What does user have to contain? The function's author likely expects it to have name and email at minimum, but it is not enforced. It is possible to call
createUser({ username: 'Maaz' })
and reach an error at a later stage. With TypeScript, I can express my expectations:
function createUser(user: {
name: string;
email: string;
}) {
return ${user.name} (${user.email});
}
Now, if someone calls
createUser({})
or
createUser({ age: 42 })
the TypeScript compiler will point it out.
Runtime Errors vs. Compile-Time Errors
JavaScript typically finds out about errors at runtime, and TypeScript will find them before runtime.
JavaScript:
Write code
↓
Run it
↓
Find out about errors
------------------------------------------------------------------
TypeScript:
Write code
↓
Type-check
↓
Fix errors
↓
Run it
TypeScript is not magic; it will not protect you from all errors. Databases can still fail, APIs can break, and your code can still have logical errors.
However, TypeScript can find some of those errors at a much earlier stage. This is essential since it significantly improves the developer experience.
For example, your editor can use types to provide better autocompletion and suggestions. If you are refactoring one file, it will warn you about changes that break other files, which is impossible to do in large JavaScript codebases.
Those advantages contribute to an overall better developer experience. In short, it is about developer productivity.
TypeScript Is JavaScript That Has Been Enhanced
TypeScript is not an entirely different language that exists alongside JavaScript.
const message: string = 'Hello';
console.log(message);
It builds on JavaScript by adding a static type system and other features.
const message : string = 'Hello;
console.log(message);
This extra : string is something that JavaScript does not understand. Under the hood, TypeScript compiles to JavaScript.
TypeScript
↓
Type-checker + compiler
↓
JavaScript
↓
Browser / Node.js
If you're just starting with TypeScript, think of it as an enhanced form of JavaScript or more of developer experience that helps you be more productive.
It is not that hard to learn since you already know JavaScript.
(If not, what are you even doing here? 🙃) ¯_ (ᵕ—ᴗ—)_/¯
Coming to the topic, with that foundation in place, let's look at these concepts in more detail.
Type Annotations
Type annotations are used to declare what type of value a variable should hold.
let username: string = 'Maaz';
let age: number = 20;
let isAdmin: boolean = false;
They can be used inside of functions:
function calculateTotal(price: number, quantity: number) {
return price * quantity;
}
Now this is fine:
calculateTotal(50, 3);
But this isn't:
calculateTotal("50", 3);
Function Return Types
Function can also have return types:
function getUsername(): string {
return 'Maaz';
}
In this case, we annotate
getUsername to return a string type only.
Type Inference
TypeScript can infer types in most cases:
const username = 'Maaz';
const age = 20;
There is no need to annotate those variables explicitly since TypeScript understands that username holds a string and age holds a number.
It is always a good practice to avoid annotating types unless it is necessary.
The reason is that it is tedious and does not contribute to developer productivity. A well-written TypeScript codebase doesn't necessarily need explicit type annotations everywhere.
Interfaces and Type Aliases
Most real-world applications deal with objects of some sort. For instance, here is how a user can be structured:
{
id: 101,
name: 'Maaz',
email: 'maaz@example.com'
}
You can use an interface to describe the shape of the object:
interface User {
id: number;
name: string;
email: string;
}
Then, instead of passing
function sendEmail(user: {
id: number;
name: string;
email: string;
}) {
console.log(user.email);
}
I can pass User interface as:
function sendEmail(user: User) {
console.log(user.email);
}
What is the point of doing that?
Well, anyone reading my code will know what to expect from the user. They do not have to go to the function definition to see what fields are necessary.
TypeScript allows using
typeto achieve the same effect that interface produces and here comesType Aliases.
type User = {
id: number;
name: string;
email: string;
};
There is not a significant difference between those two structures. At least not for the case of objects.
The real difference begins when you start combining types.
When I use interfaces, I can extend them:
interface User {
id: number;
name: string;
}
interface Admin extends User {
permissions: string[];
}
Type aliases can do similar things by defining unions and intersections:
type OrderStatus = 'pending' | 'paid' | 'cancelled';
type Customer = User & {
phone: string;
};
Interfaces have other advantages, such as declaration merging and being commonly used in the ecosystem.
So, there is no absolute truth in which one is better than the other. They both have their purposes.
Union Types: Expressing Logical OR
Suppose I want to define possible statuses for an order:
pending
paid
cancelled
I can define a union of strings:
type OrderStatus = 'pending' | 'paid' | 'cancelled';
Now, I can define a variable:
let status: OrderStatus = 'pending';
And assign it any of the values:
status = 'paid'
status = 'cancelled'
I cannot assign it 'shipped', since it is not part of the union.
That can be helpful in cases when a variable can hold more than one type:
let productId: string | number;
productId = 101;
productId = 'PROD-101';
As with interfaces and type aliases, there is a nuance.
A union of types allows the variable to be either of them; however, I have to account for all possibilities in my code.
function printId(id: string | number) {
if (typeof id === 'string') {
console.log(id.toUpperCase());
} else {
console.log(id.toFixed(0));
}
}
Before the condition, TypeScript thinks that id is a union of
string | number.
After evaluating the first case, it realizes that id is a string inside the first block and number inside the second block.
This process is called type narrowing. It allows TypeScript to determine a more specific type based on conditions in your code.
Intersection Types: Expressing Logical AND
If a union type defines logical OR, an intersection type defines logical AND.
A | B // Union type
A & B // Intersection type
For instance, if I have a
User:
type User = {
id: number;
name: string;
};
And a
ContactInfo:
type ContactInfo = {
email: string;
phone: string;
};
I can create a Customer by combining them:
type Customer = User & ContactInfo;
Then, I will have to provide values for all four fields:
const customer: Customer = {
id: 101,
name: 'Maaz',
email: 'maaz@example.com',
phone: '555-1234'
};
It will come in handy when we need to combine smaller structures into a bigger one. A good rule of thumb to remember is:
A | B // A or B
A & B // A and B
Generic Functions: Reusable Code Without Sacrificing the Developer Experience
Generics may seem intimidating at first. But most often, they are not needed.
The primary use case for generics is creating reusable functions without losing the type information.
This is what a generic function may look like:
function getFirst<T>(items: T[]): T {
return items[0];
}
It is essentially the same as:
function getFirst<T>(items: string[]): string {
return items[0];
}
But the first function works with any array like:
const name = getFirst(['Alex', 'Sam']);
const number = getFirst([10, 20, 30]);
unlike second function which only takes array of type string.
This makes the function reusable while allowing TypeScript to continue doing its job:
string[] → T is string → result is string
number[] → T is number → result is number
Generic constraints
Sometimes, you may want to add constraints to your generics. For instance:
function getLength<T>(value: T) {
return value.length;
}
In this case, TypeScript will throw an error. Because T can be any type, and not all types have a length property.
We can instruct TypeScript to allow this function only when T has a length property of type number:
function getLength<T>(value: T): number {
return value.length;
}
Now this is allowed:
getLength('Hello');
getLength([1, 2, 3]);
But this is not:
getLength(100);
Constraints allow adding additional requirements to generics.
tsconfig.json: Configuration File for Your Project
tsconfig.json is a configuration file that lets you tell TypeScript how to behave.
It looks like this:
json{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"strict": true
}
}
tsconfig.json is a configuration file for TypeScript projects. It controls how TypeScript type-checks and, when applicable, emits your code.
The strict flag
"strict": true
is useful; it enables a set of rules that make TypeScript more strict about its type checking. It is recommended to enable it for any new project.
The target option
"target": "ES2022"
determines which version of JavaScript TypeScript should emit. It should generally match the JavaScript runtime environments you need to support.
The module
"module": "NodeNext"
option determines how TypeScript handles JavaScript modules such as import and export. The appropriate setting depends on your runtime or build tool.
The build toolchain that you choose will also influence how TypeScript is used in your project, so it is possible that
tsc is not even used to compile your application code. The point is to understand what the tsconfig.json is used for.
How Does TypeScript Become JavaScript?
TypeScript compiles to JavaScript. Here is a simple example:
const price: number = 100;
After compilation, the resulting JavaScript will most likely look like this:
const price = 100;
The colon and the number after it are gone. That happens because the type information is only needed by the TypeScript compiler and then erased.
So, type information exists during TypeScript's development and type-checking process, but most type annotations are erased from the emitted JavaScript.
The general flow looks like this:
.ts source
↓
TypeScript compiler / build tool
↓
type-check + transformation
↓
JavaScript
↓
Runtime
This is also the reason why the following pattern does not work:
type User = {
name: string;
};
// Assume that data is coming from an unknown source
const data = {
name: 123
};
const user: User = data;
In this case, the type annotation helps the developer but does not serve any run-time purpose. It does not protect against erroneous or malicious data.
External or untrusted data should be validated at runtime before your application relies on its structure.
Static types protect your code during development; they are not a replacement for run-time validation of untrusted data.
What to Learn First
TypeScript is a programming language with a very extensive type system. You can spend months or even years trying to grasp every possible nuance of it. However, there is no need to do so at the beginning.
You should learn TypeScript in the following order:
JavaScript
↓
Basic types
↓
Type inference
↓
Interfaces + type aliases
↓
Union types
↓
Intersection types
↓
Generics
↓
tsconfig.json
↓
TypeScript in real projects
After you become familiar with those concepts and start using them regularly, you will find it much easier to explore more advanced topics.
TypeScript touches on many interesting ideas, but it is best to learn them at the right time. For a great developer experience, it is important to learn the concepts that make code easier to read and maintain.
Summary
You just learned everything that I believe is important to learn when you are starting out with TypeScript.
To recap:
TypeScript is mostly about adding more information(mostly types) to make code clearer to the computer and, by extension, other developers.
Using interfaces and type aliases, one can define what shape objects have to be in for TypeScript to accept them as valid.
Unions and intersections allow defining types as logical OR and AND.
Generic functions allow reusing code while preserving the type information.
tsconfig.json is used to configure TypeScript to your liking.
TypeScript is compiled to JavaScript, which means that it adds features on top of the existing language.
TypeScript may not be necessary for everyone, but it is difficult to deny its benefits in larger projects.
It is important to understand that those benefits come from the fact that TypeScript does not replace JavaScript but rather enhances it.



