TypeScript Fundamentals
A beginner-friendly introduction to TypeScript and how it enhances JavaScript development.
TypeScript has become an essential tool in modern web development, extending JavaScript with type definitions that can help catch errors early, improve documentation, and enhance the development experience. In this tutorial, we’ll explore the fundamentals of TypeScript and how it can improve your JavaScript projects.
What is TypeScript?
TypeScript is a strongly typed programming language developed and maintained by Microsoft. It is a superset of JavaScript, which means any valid JavaScript code is also valid TypeScript code. The key difference is that TypeScript adds static type checking.
B[TypeScript Compiler]B --> C[JavaScript Code]
C --> D[Browser/Node.js]
“ —>
When you write TypeScript, your code goes through a compilation step that converts it to JavaScript. During this process, the compiler checks your code for type errors, but these types are erased in the final JavaScript output.
Why Use TypeScript?
TypeScript offers several advantages over plain JavaScript:
- Static Type Checking: Catch type-related errors at compile time instead of runtime
- Better IDE Support: Enhanced autocompletion, navigation, and refactoring
- Self-Documenting Code: Type annotations serve as inline documentation
- Safer Refactoring: The compiler catches issues when you change your code
- Enhanced Team Collaboration: Clearer interfaces between components
Setting Up TypeScript
To start using TypeScript, you need to install it and set up a project:
# Install TypeScript globally
npm install -g typescript
# Create a new directory for your project
mkdir my-ts-project
cd my-ts-project
# Initialize a new npm project
npm init -y
# Install TypeScript as a dev dependency
npm install --save-dev typescript
# Generate a tsconfig.json file
npx tsc --init
The generated tsconfig.json
file contains configuration options for the TypeScript compiler. Here’s a basic version with common settings:
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Basic Types
TypeScript provides several basic types that you can use to annotate your variables, function parameters, and return values:
// Basic types
let isDone: boolean = false;
let decimal: number = 6;
let color: string = "blue";
let list: number[] = [1, 2, 3];
let tuple: [string, number] = ["hello", 10];
let u: undefined = undefined;
let n: null = null;
// The any type (try to avoid when possible)
let notSure: any = 4;
notSure = "maybe a string";
notSure = false;
// The unknown type (safer than any)
let userInput: unknown;
userInput = 5;
userInput = "hello";
// Need to check type before using unknown values
if (typeof userInput === "string") {
console.log(userInput.toUpperCase());
}
// Void (no return value)
function logMessage(message: string): void {
console.log(message);
}
// Never (function that never returns)
function throwError(message: string): never {
throw new Error(message);
}
Type Inference
TypeScript can often infer types automatically, so you don’t always need to explicitly annotate everything:
// TypeScript infers these types automatically
let x = 3; // Inferred as number
let y = "hello"; // Inferred as string
let z = [1, 2, 3]; // Inferred as number[]
let greeting = (name) => {
// Parameter 'name' implicitly has 'any' type
return `Hello, ${name}!`;
};
// Adding type annotation for clarity or when inference isn't enough
let greeting2 = (name: string): string => {
return `Hello, ${name}!`;
};
Interfaces
Interfaces define the structure of objects and can be used to create reusable type definitions:
// Define an interface
interface Person {
firstName: string;
lastName: string;
age?: number; // Optional property
readonly id: number; // Can't be changed after initialization
}
// Use the interface
function greet(person: Person): string {
return `Hello, ${person.firstName} ${person.lastName}!`;
}
const john: Person = {
firstName: "John",
lastName: "Doe",
id: 123,
};
console.log(greet(john)); // Hello, John Doe!
// Error: Property 'id' is readonly
// john.id = 456;
// Interface extending another interface
interface Employee extends Person {
jobTitle: string;
department: string;
}
const jane: Employee = {
firstName: "Jane",
lastName: "Smith",
id: 456,
jobTitle: "Developer",
department: "Engineering",
};
Type Aliases
Type aliases create new names for types. They are similar to interfaces but can name primitive types, unions, tuples, and other types that you’d like to use more than once:
// Type alias for a primitive
type ID = string | number;
// Type alias for an object type
type Point = {
x: number;
y: number;
};
// Type alias with a union type
type Result = "success" | "failure" | "error";
// Using type aliases
function printId(id: ID) {
console.log(`ID: ${id}`);
}
function plotPoint(point: Point) {
console.log(`X: ${point.x}, Y: ${point.y}`);
}
function handleResult(result: Result) {
if (result === "success") {
console.log("Operation successful");
} else {
console.log(`Operation failed with status: ${result}`);
}
}
Functions in TypeScript
TypeScript enhances JavaScript functions with parameter and return type annotations:
// Function with typed parameters and return type
function add(a: number, b: number): number {
return a + b;
}
// Optional parameters
function buildName(firstName: string, lastName?: string): string {
return lastName ? `${firstName} ${lastName}` : firstName;
}
// Default parameters
function greeting(name: string = "Guest"): string {
return `Hello, ${name}!`;
}
// Rest parameters
function sum(...numbers: number[]): number {
return numbers.reduce((total, num) => total + num, 0);
}
// Function type
let mathOperation: (x: number, y: number) => number;
mathOperation = add;
console.log(mathOperation(5, 3)); // 8
// Function overloads
function processValue(value: number): number;
function processValue(value: string): string;
function processValue(value: number | string): number | string {
if (typeof value === "number") {
return value * 2;
} else {
return value.repeat(2);
}
}
console.log(processValue(10)); // 20
console.log(processValue("Hello")); // HelloHello
Classes
TypeScript adds type annotations and visibility modifiers to JavaScript classes:
class Animal {
// Property with visibility modifier and type
private name: string;
protected age: number;
readonly species: string;
// Constructor
constructor(name: string, age: number, species: string) {
this.name = name;
this.age = age;
this.species = species;
}
// Method with return type
public makeSound(): string {
return "Some generic sound";
}
}
// Inheritance
class Dog extends Animal {
private breed: string;
constructor(name: string, age: number, breed: string) {
super(name, age, "Canine");
this.breed = breed;
}
// Override method
public makeSound(): string {
return "Woof!";
}
// Additional method
public getInfo(): string {
// Can access protected members from parent
return `This dog is ${this.age} years old and is a ${this.breed}`;
}
}
const myDog = new Dog("Rex", 3, "German Shepherd");
console.log(myDog.makeSound()); // Woof!
console.log(myDog.getInfo()); // This dog is 3 years old and is a German Shepherd
Access Modifiers
TypeScript provides three access modifiers:
- public - (default) Accessible anywhere
- private - Only accessible within the containing class
- protected - Accessible within the containing class and derived classes
Generics
Generics allow you to create reusable components that work with a variety of types rather than a single one:
// Generic function
function identity<T>(arg: T): T {
return arg;
}
let output1 = identity<string>("myString");
let output2 = identity(42); // Type argument inferred as number
// Generic interface
interface Box<T> {
value: T;
}
let stringBox: Box<string> = { value: "hello" };
let numberBox: Box<number> = { value: 42 };
// Generic class
class Queue<T> {
private data: T[] = [];
push(item: T): void {
this.data.push(item);
}
pop(): T | undefined {
return this.data.shift();
}
}
const numberQueue = new Queue<number>();
numberQueue.push(10);
numberQueue.push(20);
console.log(numberQueue.pop()); // 10
// Generic constraints
interface Lengthwise {
length: number;
}
function logLength<T extends Lengthwise>(arg: T): void {
console.log(`Length: ${arg.length}`);
}
logLength("hello"); // Length: 5
logLength([1, 2, 3]); // Length: 3
// Error: number doesn't have a length property
// logLength(123);
Union and Intersection Types
TypeScript allows you to combine types using union and intersection operators:
// Union type (OR)
function formatId(id: string | number) {
if (typeof id === "string") {
return id.toUpperCase();
}
return `ID-${id}`;
}
console.log(formatId("abc")); // ABC
console.log(formatId(123)); // ID-123
// Intersection type (AND)
interface Loggable {
log(): void;
}
interface Serializable {
serialize(): string;
}
type LoggableAndSerializable = Loggable & Serializable;
class Product implements LoggableAndSerializable {
constructor(private name: string, private price: number) {}
log(): void {
console.log(`Product: ${this.name}, Price: ${this.price}`);
}
serialize(): string {
return JSON.stringify({ name: this.name, price: this.price });
}
}
Type Assertions
Sometimes you’ll have information about the type of a value that TypeScript can’t know about:
// Type assertion with 'as'
let someValue: unknown = "this is a string";
let strLength: number = (someValue as string).length;
// Alternative syntax with angle brackets (not usable in JSX)
let otherLength: number = (<string>someValue).length;
// Non-null assertion operator
function getLength(str: string | null): number {
// Use ! to assert that str is not null
return str!.length;
}
Be cautious with type assertions and non-null assertions as they can lead to runtime errors if used incorrectly.
Enums
Enums allow you to define a set of named constants:
// Numeric enum
enum Direction {
North, // 0
East, // 1
South, // 2
West, // 3
}
let myDirection: Direction = Direction.North;
console.log(myDirection); // 0
// Explicit values
enum HttpStatus {
OK = 200,
Created = 201,
BadRequest = 400,
Unauthorized = 401,
NotFound = 404,
}
function handleResponse(status: HttpStatus) {
if (status === HttpStatus.OK) {
console.log("Request successful");
} else if (status >= HttpStatus.BadRequest) {
console.log("Error occurred");
}
}
// String enum
enum Color {
Red = "RED",
Green = "GREEN",
Blue = "BLUE",
}
let favoriteColor: Color = Color.Blue;
console.log(favoriteColor); // BLUE
Literal Types
Literal types allow you to specify the exact value a string, number, or boolean must have:
// String literal type
type Direction = "North" | "East" | "South" | "West";
let direction: Direction = "North";
// direction = "Northeast"; // Error: Type '"Northeast"' is not assignable to type 'Direction'
// Numeric literal type
type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;
function rollDice(): DiceRoll {
return (Math.floor(Math.random() * 6) + 1) as DiceRoll;
}
Utility Types
TypeScript includes several utility types to facilitate common type transformations:
// Partial - makes all properties optional
interface Todo {
title: string;
description: string;
completed: boolean;
}
function updateTodo(todo: Todo, fieldsToUpdate: Partial<Todo>): Todo {
return { ...todo, ...fieldsToUpdate };
}
const todo1: Todo = {
title: "Learn TypeScript",
description: "Study the basics",
completed: false,
};
const todo2 = updateTodo(todo1, { completed: true });
// Readonly - makes all properties readonly
const readonlyTodo: Readonly<Todo> = {
title: "Learn TypeScript",
description: "Study the basics",
completed: false,
};
// readonlyTodo.completed = true; // Error: Cannot assign to 'completed' because it is a read-only property
// Pick - creates a type with a subset of properties
type TodoPreview = Pick<Todo, "title" | "completed">;
const todoPreview: TodoPreview = {
title: "Learn TypeScript",
completed: false,
};
// Omit - creates a type without certain properties
type TodoWithoutDescription = Omit<Todo, "description">;
const todoNoDesc: TodoWithoutDescription = {
title: "Learn TypeScript",
completed: false,
};
// Record - creates an object type with specific key and value types
type PageInfo = {
title: string;
url: string;
};
const pageMap: Record<string, PageInfo> = {
home: { title: "Home", url: "/" },
about: { title: "About", url: "/about" },
contact: { title: "Contact", url: "/contact" },
};
Working with External Libraries
When working with JavaScript libraries that don’t have TypeScript type definitions, you can use declaration files:
// Create a declaration file (e.g., types.d.ts)
declare module "some-untyped-module" {
export function doSomething(value: string): number;
export function doSomethingElse(): void;
}
// Now you can use it with type checking
import { doSomething } from "some-untyped-module";
const result = doSomething("test");
Many popular libraries already have type definitions available via the DefinitelyTyped project, which you can install using npm:
npm install --save-dev @types/lodash
TypeScript with React
TypeScript works great with React. Here’s a simple component with TypeScript:
import React, { useState, FC } from "react";
// Define props interface
interface CounterProps {
initialCount?: number;
label: string;
}
// Function component with TypeScript
const Counter: FC<CounterProps> = ({ initialCount = 0, label }) => {
const [count, setCount] = useState<number>(initialCount);
return (
<div>
<h2>{label}</h2>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(count - 1)}>Decrement</button>
</div>
);
};
export default Counter;
// Using the component
<Counter label="My Counter" initialCount={5} />;
Best Practices
- Start with the
strict
flag enabled: Catch more issues early - Avoid
any
type when possible: It defeats TypeScript’s purpose - Use interfaces for objects that will be extended: Interfaces can be augmented later
- Use type aliases for unions, primitives, and more complex types
- Let TypeScript infer types when obvious: Don’t over-annotate
- Add explicit return types to functions: Especially public APIs
- Make your types as specific as possible: Avoid overly broad types
- Use readonly modifiers when properties shouldn’t change
- Consider null vs. undefined: Be consistent in your codebase
TypeScript Compiler Options
The TypeScript compiler (tsc
) has many options that can be configured in tsconfig.json
:
{
"compilerOptions": {
// Target ECMAScript version
"target": "es2016",
// Module system
"module": "commonjs",
// Strictness flags
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
// Module resolution
"moduleResolution": "node",
"esModuleInterop": true,
// Output options
"outDir": "./dist",
"sourceMap": true,
// Additional checks
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.spec.ts"]
}
Conclusion
TypeScript adds an additional layer of safety and maintainability to JavaScript projects. By catching type errors at compile time, providing better IDE support, and making code self-documenting, TypeScript can significantly improve your development experience.
Start small by adding TypeScript to a portion of your JavaScript project, gradually increasing the strictness level and type coverage. Even with minimal type annotations, TypeScript can infer many types and provide valuable feedback.
As you become more comfortable with TypeScript, you’ll discover advanced features that can help you model complex systems and ensure code correctness. The TypeScript community is large and active, with excellent documentation, examples, and packages to help you on your journey.