TypeScript Fundamentals: Getting Started (Part 1/2)

TypeScript Fundamentals: Getting Started (Part 1/2)

Nowadays JavaScript is everywhere, powering websites, mobile apps, desktop software, and games on both client and server. Its ubiquity has made it an essential tool for developers in nearly every domain, creating a universal programming ecosystem that transcends traditional platform boundaries and specializations.

JavaScript's flexibility is one of its greatest strengths, allowing quick prototyping given its dynamic nature, but it can sometimes be its biggest weakness. Consider this code:

const user = { name: "Raul" };
user.age.toFixed(2);

Once deployed, this code could result in your website crashing.

Console error while trying to access a property of undefined
A common JavaScript runtime error: trying to access a property or method, like toFixed(), on an undefined value.

But with TypeScript, the same code fails instantly in your editor:

Code editor error while trying to access a property of undefined
TypeScript provides instant feedback in the editor. Here, it warns us at compile time about accessing user.age on an object that might be undefined, preventing a potential runtime error.

TypeScript catches the mistake as you type, not after deployment.

This is TypeScript’s superpower: it brings structure to JavaScript’s flexibility, keeping your sanity intact as projects grow.

But TypeScript wasn’t built in a vacuum. Before we dive into code, let’s take a step back and look at why TypeScript was created and what problems it aimed to solve. Knowing the why behind TypeScript makes it easier to learn and apply with confidence.

This is the first part of a two-part series. By the end of these installments, you’ll not only be able to write basic TypeScript, but also understand why it exists and how it fits into the JavaScript ecosystem.

Now, without further ado, let’s dive in.

JavaScript’s Backstory

JavaScript was created in 1995 as a simple scripting language to add interactivity to otherwise static HTML pages. It was originally intended for small snippets of code embedded directly into HTML, and writing more than a few dozen lines was uncommon. At the time, it was never designed for building large-scale applications.

Over time, though, JavaScript became increasingly popular, and web developers started using it to create more complex and interactive experiences. Its versatility led to adoption beyond browsers, with the rise of server-side JavaScript through Node.js, first released in 2009.

Given its original purpose for small-scale scripting, JavaScript was designed as a dynamically typed language, a choice that worked well for its early goals but introduced challenges as applications grew larger.

Statically vs Dynamically Typed Languages

In statically typed languages like C, C++, and Java, you explicitly define variable types. The compiler checks for type-related errors before the program runs, catching mistakes early, like trying to access a property that doesn't exist on an object.

Dynamically typed languages like JavaScript, by contrast, don't enforce types at compile time. Variables can hold any value, and many mistakes go unnoticed until runtime, leading to unexpected errors. We saw an example of this earlier: trying to call .toFixed() on a missing age property caused a runtime error that could crash the application.

This kind of flexibility is useful for small scripts and quick iterations. But as codebases grow larger, the lack of type safety makes maintenance harder. Bugs slip through until runtime, and refactoring becomes risky and error-prone.

These growing pains are exactly what TypeScript was created to address. It introduces optional static typing, improves tooling, and makes JavaScript development safer without sacrificing the flexibility that made it so popular in the first place.

The next couple of sections provide valuable context that will make the core concepts easier to understand. However, if you’d prefer to jump straight into the syntax, you can skip ahead to the Basic Concepts section.

TypeScript’s Arrival

The 2010s saw an explosion of JavaScript frameworks. Tools like AngularJS, Ember.js, and React enabled developers to build increasingly structured and interactive web applications. Combined with the rise of Node.js, JavaScript evolved from a browser-only scripting language into a language powering full-stack development.

But as applications grew more complex, JavaScript’s dynamic nature became a liability. The lack of type safety made refactoring risky, tooling was limited, and runtime errors often slipped through the cracks.

For example, consider a common refactor:

// Before refactor: property is named 'email'
function sendEmail(user) {
return user.email.toLowerCase();
}
// After refactor: 'email' renamed to 'emailAddress' in user model,
// but this function wasn't updated. No compile error, but crashes at runtime.
function sendEmail(user) {
return user.email.toLowerCase(); // Runtime error
}

JavaScript won’t warn you, it assumes you know what you’re doing. A typo or stale property access might only surface in production. With TypeScript, this kind of issue is caught immediately by the type checker, making large-scale changes safer and faster.

Microsoft had already explored solutions to these challenges. In the mid-2000s, they experimented with Script#, a project by Nikhil Kothari that compiled C# into JavaScript. While innovative, Script# required developers to abandon JavaScript entirely and introduced significant tooling overhead, limiting its adoption. The lessons from that project, however, provided valuable insights.

Armed with that experience, Microsoft took a new approach. Led by Anders Hejlsberg, the lead architect behind C#, they introduced TypeScript in 2012. Instead of replacing JavaScript, they built on top of it. As a superset, all valid JavaScript code is also valid TypeScript, enabling gradual adoption.

That compatibility proved to be TypeScript’s killer feature. Existing codebases could migrate file by file rather than being rewritten from scratch. JavaScript developers could keep writing familiar syntax and gradually introduce TypeScript’s features.

TypeScript wasn’t designed to make JavaScript statically typed. Its primary goal was to improve the developer experience. By catching bugs earlier, providing IntelliSense, enhancing code navigation, and supporting better tooling, it addressed JavaScript’s pain points without sacrificing flexibility.

A pivotal moment in TypeScript’s trajectory came in 2014 when the Angular team decided to rewrite the popular AngularJS framework using TypeScript, resulting in Angular version 2. This move was crucial in accelerating TypeScript’s widespread adoption.

Today, TypeScript is deeply integrated into modern software development. It’s supported by major frameworks like Angular, Next.js, and Vue, and used across both front-end and back-end codebases. With approximately 84 million weekly downloads on npm, TypeScript has evolved from a helpful tool into an industry standard.

How The TypeScript Compiler Works

At the heart of TypeScript is the TypeScript Compiler (tsc). While it's often called a compiler, it's technically a transpiler, since it doesn’t turn code into machine instructions like C or Rust would. Instead, it converts TypeScript into plain JavaScript that browsers and Node.js can understand.

Here’s what happens under the hood when you run tsc:

1. Type Checking

First, TypeScript checks your code for type errors. It compares your code against the types you’ve defined (or that it has inferred) and surfaces any mismatches, like trying to call a method on a number that doesn’t exist.

2. Generating The Abstract Syntax Tree (AST)

Before making any changes to the code, TypeScript generates an Abstract Syntax Tree (AST). This is an in-memory representation of the code's structure, where the code is parsed and broken down into nodes that represent various language constructs, such as variables, functions, or expressions.

To help visualize what an AST looks like, I used a VSCode extension that lets you explore the AST of a given file. Here's an example using a random file I found:

Illustration of an abstract syntax tree
A live view of the Abstract Syntax Tree (AST) using the vscode-ast extension.

The AST serves two key purposes:

  • Type Checking: TypeScript uses the AST for type inference and to analyze the types in your code, comparing them to the expected types and catching errors, like calling methods on the wrong data type.
  • Code Transformation: TypeScript also uses the AST to transform the code. These transformations include the next two steps: type stripping and transpilation.

3. Stripping Type Annotations

Since JavaScript doesn’t support types, TypeScript strips out anything it added, like type annotations or interfaces. For example, let x: number becomes just let x.

4. Transpiling Modern JavaScript Features

Depending on your target option in tsconfig.json, TypeScript may convert modern JavaScript syntax (such as async/await, arrow functions, or const/let) into older JavaScript that works in legacy environments. This is similar to how Babel helps JavaScript use cutting-edge features while ensuring compatibility with older browsers.

5. Outputting JavaScript

Finally, it writes the result to a .js or .jsx file. That output can be run in any JavaScript environment, whether it's the browser, Node.js, or a serverless function.

Getting started

To start working with TypeScript, we need to install the TypeScript package, either locally or globally, using your package manager of choice. Here, we'll install it globally with npm:

npm install -g typescript

The typescript package includes the TypeScript compiler (tsc), the language server (tsserver) that powers editor features like IntelliSense and autocompletion, as well as core libraries, type definitions, and compiler utilities.

Let’s create a simple script.ts file:

console.log("Hello!");

Now, while in the same directory as script.ts run:

tsc script.ts

You'll notice that a script.js file was generated. This is the output of the TypeScript compiler. If we open the file, you’ll notice the contents are identical. That’s because the original code contains no TypeScript-specific syntax, and since TypeScript is a superset of JavaScript, valid JavaScript compiles without changes.

Let’s now add some TypeScript-specific syntax, recompile, and check the output. Don’t worry if it looks unfamiliar, we’ll break it down later:

let sample: { a: number; b: boolean; c: string } = {
a: 2,
b: true,
c: "this is a string",
};

Run it again:

tsc script.ts

and open up script.js

var sample = {
a: 2,
b: true,
c: "this is a string",
};

Notice that the type annotations are gone (tsc strips them out entirely during compilation).

Now, let's modify our script.ts to include a type mismatch:

let a = "this is a string";
a = 5;
console.log(a);

If we now run tsc script.ts, we'll see the following error in the terminal:

Type 'number' is not assignable to type 'string'.

However, even with that error, TypeScript still emits a script.js file:

let a = "this is a string";
a = 5;
console.log(a);

And running it with node script.js we’ll see it outputs 5.

Why did it compile even with an error? This is because of the nature of tsc and how it separates the type checking and compilation step.

By default, TypeScript performs type checking, then proceeds with compilation, even if it finds errors. This means it will still emit JavaScript unless you explicitly tell it not to (with the noEmitOnError flag). While you could pass flags individually through the command line, that quickly becomes cumbersome. That’s where the tsconfig.json file comes in.

Configuring Your TypeScript Project With tsconfig.json

The tsconfig.json file tells the TypeScript compiler how to process your project. It defines the root files, sets compiler options, and controls how TypeScript behaves during compilation. This configuration file is conceptually similar to JavaScript's jsconfig.json.

While we won't delve into every available option here (given the extensive nature of the topic), it's worth noting that there are some essential compiler flags that can significantly improve the developer experience and help catch bugs early. Here are some of these options inside a rudimentary tsconfig.json:

{
"compilerOptions": {
"target": "es6",
"outDir": "./dist",
"noEmit": true,
"strict": true,
"baseUrl": "./src",
"paths": {
"@components/*": ["components/*"],
"@utils/*": ["utils/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
  • compilerOptions: This section contains options that control how TypeScript compiles your code.
    • target: Specifies the version of JavaScript your code will be compatible with (e.g., es6, es5). TypeScript will transpile your code to match this version.
    • outDir: Defines the output directory where the JavaScript files generated by transpiling your .ts files will be saved. If noEmit is set to true, TypeScript will not generate any output files, so the outDir directory will remain empty.
    • noEmit: When set to true, TypeScript will not emit JavaScript files. This option is common with modern tools like Vite or frameworks like Next.js, which use bundlers like esbuild or SWC to handle transpilation and bundling. These tools are faster and more optimized for modern development workflows, and they handle the transpiling more efficiently than TypeScript's own compiler.
    • strict: Enables all strict type-checking options, such as strictNullChecks, noImplicitAny, and others. Enabling strict mode is considered a best practice and is highly recommended because it improves code quality and makes type checking more comprehensive and predictable.
    • baseUrl: Specifies the base directory to resolve non-relative module names. Typically, this would be your src folder. This allows TypeScript to find files more easily.
    • paths: Defines custom paths or aliases for easier imports.
  • include: Specifies the files or directories that should be included in the compilation. By default, TypeScript will look for .ts files in the src folder.
  • exclude: Specifies the files or directories that should be excluded from the compilation. This is typically used to exclude node_modules, which often contains external dependencies and does not need to be compiled by TypeScript.

If you're working with vanilla JavaScript and want to easily set up TypeScript for your project, you can generate a basic tsconfig.json file by running tsc --init. This will create a default configuration that you can customize to suit your needs.

By configuring your tsconfig.json file properly, you can customize the behavior of TypeScript to suit your project’s needs and integrate seamlessly with modern build systems. Enabling strict mode, defining path aliases, and configuring the other relevant flags will help ensure a more efficient, organized, and smoother development experience.

Declaration Files

With your tsconfig.json configured, it's time to look at how TypeScript understands the types of external JavaScript code (like libraries or framework-specific features). This is where declaration files come into play.

Declaration files, identifiable by their .d.ts extension, act as type annotations for existing JavaScript code. They contain only type information, no executable logic. For instance, writing const x = 5; in a declaration file will throw an error, as these files are meant solely to describe types, not implement functionality. Think of them as type definitions that outline the structure of JavaScript code for TypeScript. A key feature of declaration files is that they can define global types that are accessible throughout your entire application.

When you import a JavaScript library, TypeScript can’t infer types from .js files. Declaration files fill this gap, enabling features like IntelliSense, catching mismatched argument types or missing properties.

You may be asking yourself, where do these files actually show up in a codebase? The answer is, they can appear in a few different places.

  • Frameworks and Global Types: Frameworks usually generate their own declaration file when scaffolding a new project. In the case of Next.js, we see a file at the root of our project called next-env.d.ts, which looks like this:

    /// <reference types="next" />
    /// <reference types="next/image-types/global" />
    // NOTE: This file should not be edited
    // See https://nextjs.org/docs/app/api-reference/config/typescript for more information.

    The /// are called triple-slash directives, and they tell TypeScript to load types from a specific directory before your code runs. For example, Next.js extends the Fetch API with extra features like the revalidate method or tags property, among others. The directive ensures TypeScript pulls these enhanced types from node_modules/next, making them globally recognized across your project. This way, you will see them as suggestions in your code editor.

  • Third-Party Libraries:

    • Libraries Written In TypeScript: Although these libraries are authored in .ts, they’re typically compiled and shipped as JavaScript. To ensure consumers still get type information, library authors emit .d.ts files alongside the compiled .js output. This is done by setting "declaration": true in tsconfig.json, so tsc generates both .js (executable) and .d.ts (type definition) files. The "types" field in package.json specifies the entry point for your type definitions. For example, if you set "types": "dist/index.d.ts", then dist should match the outDir you configured in your tsconfig.json.

    • JavaScript Libraries Without Type Definitions: If a JavaScript library doesn’t provide its own .d.ts files, you can add TypeScript support using community-maintained type definitions from the @types organization on npm. For example, install type definitions for lodash with:

      npm install @types/lodash --save-dev

In reality, most developers rarely write declaration files since modern TypeScript libraries generate them automatically, and @types packages cover many popular JavaScript tools. But wait a minute, where are all these @types definitions actually coming from? Who writes and maintains them?

To answer that, let’s take a look at the DefinitelyTyped repository.

The DefinitelyTyped repository

When Microsoft first released TypeScript, one of the biggest challenges was encouraging libraries to adopt it. Many popular libraries were written in plain JavaScript, and adding type support required time and effort that maintainers didn’t always have.

To address this, Microsoft created the DefinitelyTyped repository, a massive, community-maintained collection of .d.ts files for third-party JavaScript libraries.

Here’s how it works:

  • If a JavaScript library doesn’t include built-in type definitions, the community can contribute them to the DefinitelyTyped repo.

  • These type definitions are published under the @types scope on npm. For example, the types for react live in @types/react, and types for lodash live in @types/lodash.

  • When you install them via:

    npm install --save-dev @types/react

    The type definitions are added to your node_modules/@types directory, making them instantly available to your project.

You can inspect this folder in node_modules/@types to see all the community-maintained type definitions currently installed in your project. This directory acts as a type bridge, connecting plain JavaScript libraries to the TypeScript compiler and your editor’s IntelliSense system.

While TypeScript doesn’t ship with all of these definitions out of the box, it’s designed to automatically include them from @types once installed. This makes your tooling behave as if the library was written in TypeScript, even if it wasn’t.

Alright, enough internals. Let’s get our hands dirty and start writing some actual TypeScript!

Basic Concepts

The idea for this portion of the blog is to go over the most basic concepts. In Part II, we will go deeper into more advanced topics, but for now, the goal is to understand basic syntax and get comfortable using TypeScript’s simpler features.

Implicit Inference

When you write let x: number, you're explicitly declaring x to be of type number. However, TypeScript can often infer the type of a variable even without an explicit annotation, especially:

  • during variable initialization
  • when assigning default values to parameters
  • when determining function return types

This type inference works because the TypeScript compiler builds an abstract syntax tree (AST) to analyze your code's structure and context. Inference works great in straightforward scenarios. But when TypeScript can’t confidently determine the type, say, when a variable is declared without an initializer or is assigned from an ambiguous source, it defaults to the any type. This happens unless noImplicitAny is enabled in your tsconfig.json, which would produce an error instead.

Unassigned variables are of type any by default
Unassigned variables are of type any by default.

But what exactly is this any type anyways?

The any Type

The any type means anything goes. When you use it, you're essentially opting out of TypeScript's type system, which is ironic, since type safety is TypeScript’s main value proposition.

Technically, this is valid:

let x: any;
x = 12;
x = true;

Using the any type is like adding a @ts-ignore comment around every usage of the variable. The compiler won’t complain, no matter what you assign or how you use it.

Generally, you should steer clear of using any, except when you're actively converting a JavaScript project to TypeScript. If you see any in your code, it often indicates an opportunity to define a more precise type or use a safer option like unknown (we'll discuss this in Part II).

Inline Definitions

We are now going to move quickly through some basic examples of inline type definitions for some common primitives.

Variables:

let isActive: boolean = true;
let age: number = 30;
let userName: string = "John";

TypeScript allows you to explicitly annotate the types of your variables. In these cases, the types are straightforward: boolean, number, and string.

Arrays:

let numbers: number[] = [1, 2, 3]; // Bracket syntax
let otherArray: Array<number> = [1, 2, 3]; // Generic array syntax

Both of these declare an array of numbers. Choose whichever style you prefer, bracket syntax is more common, but the generic form is equally valid.

Functions:

function greet(name: string): string {
return `Hello, ${name}!`;
}

This function takes a string and returns a string.

const func = (a: string, b: string): void => {
// do something
};

The return type here is void, which means the function doesn’t return anything. It's commonly used for functions that perform side effects, like DOM manipulation.

const func = ({ a, b }: { a: string; b: number }): void => {
// Do something
};

You can also type destructured parameters directly, this function expects an object with a string and a number.

// Using a tuple as function parameter
// Tuples are fixed-length, ordered, array-like types
const func = (padding: [number, number, number, number]): void => {
// Do something
};

This function expects a tuple of exactly four numbers, for example, it could represent padding in clockwise order: [top, right, bottom, left].

// Async function:
async function getFavoriteNumber(): Promise<number> {
return 26;
}

With async functions, make sure to annotate the return type as Promise<T> to clarify what the function resolves to. While TypeScript can often infer this, adding the return type makes your intent clearer.

Inline type definitions are totally fine for small or simple structures. In fact, they’re a great way to get started quickly. But once your types get more complex (especially when reused), it’s better to extract them into a type or interface for clarity and maintainability. We’ll dive into those next.

Defining Types With interface and type

TypeScript provides two main ways to define custom types: interface and type. They're very similar, but there's a key difference: interfaces can only describe the shape of objects, while types can represent a wider variety of structures, like unions or intersections. In order to show you how they work, let’s imagine a simple logBook function that receives a book as a parameter.

// Using an interface
interface Book {
title: string;
pages: number;
read(): void;
}
function logBook(book: Book): void {
console.log(`${book.title} has ${book.pages} pages.`);
book.read();
}

Here, Book is an interface describing the shape of a book object. We could alternatively use a type alias like this:

// Using a type alias for the same object shape
type Book = {
title: string;
pages: number;
read(): void;
};

Both interface and type can describe object shapes like this. However, type aliases can also describe other kinds of types that interfaces cannot, for example, unions:

// Type alias describing a union type
type ID = string | number;

Notice the following conventions:

  • Both interface and type aliases use PascalCase and names are singular.
  • In TypeScript, each property is separated with semicolons (;) instead of commas (,), which is the case in regular JavaScript objects. While commas are technically allowed, semicolons are the preferred convention.

An advantage of using type and interface over inline declarations is that the error messages are often more concise, as TypeScript will only show the name of the type or interface (like Book) instead of the full inline declaration.

Another advantage of using named types like type or interface is that they can be extracted into separate files and imported as necessary. This promotes reusability, since types may come up again and again within a codebase.

Both interface and type let you define and reuse custom shapes, and you can build on top of them similarly. Since type can represent a wider range of values (including primitives, unions, and intersections), we’ll use type throughout the rest of the blog.

Optional And Default Parameters

It’s common to have optional function parameters or default values for them. TypeScript supports this with the ? modifier and standard default syntax:

function greet(name?: string, age: number = 25): string {
return `Hello, ${name ?? "Guest"}! You are ${age} years old.`;
}

In this case, the name is an optional argument in the greet function, and age has a default value of 25.

Union Types

Let’s say we’re building a small program where a user can choose a theme. You might start with something like this:

let theme: string;
theme = "light"; // OK
theme = "refrigerator"; // Also OK... but probably not what we want

The problem? Using string is too broad, it allows any value as long as it is a string. To fix this, we can use union types:

let theme: "light" | "dark" | "system";

Now, theme is limited to just these three values. If we try assigning "refrigerator", TypeScript will show an error.

Unions aren’t just for literal strings, they’re also handy when a variable can be one of several types:

let id: string | number;

This means id can be either a string or a number. Super useful for APIs that might return different formats for the same field, for instance, an API that returns an ID as either "42" or 42.

Conclusion

TypeScript transforms JavaScript’s flexibility and dynamic nature into a more robust, predictable experience, catching errors before they reach production, enhancing your editor’s tooling, and giving you the confidence to refactor and scale large codebases. In Part 1, we covered TypeScript’s origins, how the compiler works, basic configuration, and core language features.

In the next installment, we’ll dive deeper into more advanced topics like utility types, operators and modifiers, TypeScript with React, and generics.

Thank you for following along, see you in Part 2!


Bonus

  • Highly recommend this documentary if you are interested in learning more about the story of TypeScript and how it came to be.
  • Did you know? Visual Studio Code (2015) was built using TypeScript and originally focused exclusively on JavaScript/TypeScript. By integrating TypeScript’s tsserver engine, it delivered groundbreaking features like IntelliSense and real-time error checking for JavaScript and TypeScript. Before 2016, editors like Eclipse or Sublime Text required fragmented, language-specific plugins for basic tooling. Microsoft’s breakthrough came when they generalized tsserver’s success into the Language Server Protocol (LSP). Released in 2016, LSP allowed any language to plug in its own “language server”, decoupling editor features from hardcoded syntax rules. This turned VS Code, and eventually editors like Neovim and JetBrains, into universal tools. TypeScript didn’t just improve JavaScript; it redefined how all languages interact with code editors.

Sources