DEV Community

JSDevJournal
JSDevJournal

Posted on • Originally published at jsdevjournal.com

The Complete 2023 Guide to Learning TypeScript - From Beginner to Advanced

Hello friend! Are you ready to take your JavaScript skills to the next level by learning TypeScript? Grab a hot drink and get comfortable, because we're going to dive deep into the wonderful world of typed JavaScript.

Introduction

Let's start with the basics. TypeScript is a superset of JavaScript that adds optional static typing and class-based object-oriented programming to the language. The key word here is optional - you can use as much or as little TypeScript as you want!

The first thing we need to know is that TypeScript gets compiled down to regular JavaScript. That means you can use it on any project you'd use regular JS for, like Node.js or frontend web dev. The TypeScript compiler will convert the TypeScript code into JavaScript.

Get Started

To start using TypeScript, you need to install it first. Let's open up a terminal and run:

npm install -g typescript

This will install the TypeScript compiler globally on your machine. The compiler is called tsc (which stands for TypeScript compiler).

Now let's convert a simple JavaScript file to TypeScript. Create a file called main.js and paste the following:

function greet(person) {
  console.log("Hello, " + person);
}

greet("Maria");
Enter fullscreen mode Exit fullscreen mode

This is a basic JS function that greets a person by name. Now rename the file to main.ts - this tells TypeScript it's a TS file.

Let's add some types! Change the function signature to:

function greet(person: string) {

} 
Enter fullscreen mode Exit fullscreen mode

By adding : string, we've defined that the person parameter must be a string. If we passed anything else, we'd get a compiler error. This is type safety in action!

The core primitive types in TS are string, number, boolean, null, undefined, symbol and any (allows anything). But we can also define complex types like objects, arrays, tuples, enums and more. I'll go over those next.

Let's also add an interface for a User object:

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

function greet(user: User) {
  console.log("Hello, " + user.name); 
}

let user = { name: "Maria", id: 1 }

greet(user);
Enter fullscreen mode Exit fullscreen mode

Here we defined a User interface with name and id properties, then created a user object matching the interface shape. The greet function expects a User argument thanks to the : User type annotation. This catches errors early!

That's the basics of types covered. We made our code safer and well documented.

Sounds good, let's keep going!

Classes in TypeScript

Next up is classes in TypeScript. Classes allow you to use object-oriented programming patterns like inheritance and encapsulation.

Let's create a simple Animal class:

class Animal {
  name: string;

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

  move() {
    console.log(`${this.name} is moving.`);
  }
}

let a = new Animal("Leo");
a.move(); // Leo is moving
Enter fullscreen mode Exit fullscreen mode

We declare properties like name up front, then define a constructor to initialize them. The move method logs a simple message.

To extend the class:


class Dog extends Animal {
  woof() {
    console.log("Woof woof!"); 
  }
}

let d = new Dog("Rex");
d.move(); // Rex is moving
d.woof(); // Woof woof!
Enter fullscreen mode Exit fullscreen mode

Dog inherits from Animal and we can call the base move method, plus woof which is unique to Dog.

TypeScript enforces that class properties are initialized - it will error if you forget to assign this.name in the constructor, for example. This helps catch bugs!

We can also add access modifiers like public or private:

class Car {
  private speed = 0;

  accelerate() {
    this.speed++; 
  }

  getSpeed() {
    return this.speed;
  }
}

let c = new Car();
c.accelerate();
c.getSpeed(); // 1
c.speed; // Error - speed is private
Enter fullscreen mode Exit fullscreen mode

The private speed can only be accessed within the Car class. This enforces encapsulation and data hiding best practices.

So in summary:

  • Classes allow OOP patterns like inheritance
  • Properties must be initialized
  • Can use access modifiers like public and private
  • Much safer than plain JS prototypes

Generics in TypeScript

Next up - generics in TypeScript. Generics provide reusable code that can work with different types.

For example, let's make a simple identity function:

function identity(arg: number): number {
  return arg;
}
Enter fullscreen mode Exit fullscreen mode

This takes a number and returns a number. But we can make it work with any type using generics:

function identity<T>(arg: T): T {
  return arg;
}

let output = identity<string>("myString");
Enter fullscreen mode Exit fullscreen mode

By using , we've made this function generic. Now we can pass in a string, or a boolean, or anything else without duplication.

We can also create generic classes. Here's an example:

class GenericNumber<T> {
  zeroValue: T;
  add: (x: T, y: T) => T;
}

let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function(x, y) { return x + y };
Enter fullscreen mode Exit fullscreen mode

By declaring a generic class with , we can then use T as a placeholder type that will be filled in later when instantiated.

The benefits are:

  • Remove duplication - writing generic functions like identity once
  • Type safety - interfaces enforce contracts
  • Flexibility - generic components can be reused
  • Generics are essential for reusable code in TypeScript.

Enums in TypeScript

Now let's talk about enums in TypeScript. Enums allow you to define a set of named constants.

For example, let's create an enum for possible status values:

enum Status {
  Ready,
  Waiting,
  Done
}
Enter fullscreen mode Exit fullscreen mode

We can access these values using Status.Ready, Status.Waiting, etc. By default, the values are auto-incremented starting from 0.

We can also initialize the values manually:

enum Status {
  Ready = 1, 
  Waiting = 2,
  Done = 3
}
Enter fullscreen mode Exit fullscreen mode

Now Ready is 1, Waiting is 2, and so on.

Why use enums over plain strings or numbers?

  • Self documenting code - Status.Ready is clearer than just 1
  • Type safety - only allow valid status values
  • Refactoring friendly - can rename values easily

Some use cases for enums:

  • States or status values
  • Types of errors
  • Access control levels

A couple limitations to note:

  • Enums only allow either strings or numbers, not both mixed
  • Enums themselves are still just JavaScript objects at runtime But overall, enums are super useful for managing collections of constants in a type safe way.

Partial in TypeScript

Onward it is! Now let's talk about some handy utility types that come built-in with TypeScript.

For example, we can use Partial to make an object's properties optional:

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

function updateUser(user: Partial<User>) {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

By using Partial, we've made both id and name optional.

Some other useful utilities:

  • Readonly - Makes properties readonly
  • Required - Makes properties required
  • Record - Maps properties to a type
  • Pick - Creates a subset of properties
  • Omit - Creates an object omitting some properties

Here are some examples:

interface Book {
  title: string;
  pages: number;
  author: string;
}

// Make all properties readonly
type ReadonlyBook = Readonly<Book>; 

// Make pages optional
type PartialBook = Partial<Book>;

// Pick only title and author
type BookPreview = Pick<Book, 'title' | 'author'>;

// Omit pages
type ShortBook = Omit<Book, 'pages'>;
Enter fullscreen mode Exit fullscreen mode

These utility types are great for catching errors early and clearly defining contracts. They help remove duplication.

For example, you can share a reusable userReducer that uses the Partial utility instead of defining a separate IPartialUser interface.

Namespaces and Modules in TypeScript

Next up - namespaces and modules in TypeScript.

Namespaces allow you to logically group code under a named object. This can help avoid collisions in the global namespace.

For example:

namespace MyLib {

  export interface User {
    name: string;
  }

  export function logUser(user: User) {
    console.log(user.name);
  }

}

let u = { name: "Jack" };
MyLib.logUser(u);
Enter fullscreen mode Exit fullscreen mode

We wrap related code in the MyLib namespace. The exports are accessible using MyLib.logUser.

Modules are another way to organize code. Use import and export instead of namespaces:

// my-lib.ts

export interface User {
  name: string;
}

export function logUser(user: User) {
  console.log(user.name);
}
Enter fullscreen mode Exit fullscreen mode
// main.ts 

import { User, logUser } from './my-lib';

let u = { name: "Jack" };
logUser(u);
Enter fullscreen mode Exit fullscreen mode

Namespaces simply wrap globals while modules work with imports and exports.

In general, prefer modules over namespaces - they enforce cleaner separation between files. Namespaces are useful for grouping together many small utility functions.

Conceptually:

  • Namespaces: Globally accessed through dot notation
  • Modules: Explicit imports and exports

Let's shift gears now and talk about using TypeScript with popular libraries like React.

TypeScript with React

React and TypeScript work extremely well together. TypeScript can catch many errors in React code that would otherwise end up as runtime bugs.

For example, TypeScript can verify that components receive the correct props they expect:

interface Props {
  message: string;
}

function Greeter(props: Props) {
  return <div>{props.message}</div>
}

// Error - message prop required
<Greeter />
Enter fullscreen mode Exit fullscreen mode

We declare that Greeter requires a message prop, so TypeScript will error if we don't provide one.

We can also define types for state and props in React components:

type State = {
  count: number; 
}

type Props = {
  initialCount: number;
}

class Counter extends React.Component<Props, State> {
  state = { count: this.props.initialCount }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

This provides end-to-end type safety from parent components all the way down through the state and UI.

Some best practices for React + TS:

  • Type declare components and prop interfaces
  • Use types for state and props
  • Type check redux state slices
  • Extract complex prop types into interfaces

Using TypeScript with React does require some learning up front, but pays off exponentially in the long run by preventing so many bugs!

TypeScript with NodeJS

let's explore using TypeScript on the backend with Nodejs and Express.

Setting up TypeScript for a Node server is straightforward:

Install dependencies

npm install express body-parser typescript ts-node @types/node @types/express

Create a tsconfig.json file

Create a server.ts file

For example:

// server.ts

import express from 'express';

const app = express();

app.get('/', (req, res) => {
  res.send('Hello World');
});

app.listen(3000, () => {
  console.log('Server started on port 3000');
});
Enter fullscreen mode Exit fullscreen mode

Run server using ts-node

ts-node server.ts

That's a simple Express server with TypeScript!

Some benefits TypeScript adds:

  • Route handler arguments and response types
  • Request body and query param types
  • Custom middleware types
  • Configuration object types

For example:

// Require body name to be a string
app.post('/user', (req: {body: {name: string}}, res: Response) => {
  // ...
})
Enter fullscreen mode Exit fullscreen mode

This catches errors early like incorrect property types.

For middleware:


const logger = (req: Request, res: Response, next: NextFunction) => {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

So in summary - TypeScript can help catch a whole class of bugs at dev time on the backend!

Some Best Practices and Patterns

Let's talk about some best practices and patterns for using TypeScript in large scale applications.

Here are some tips:

  • Use interfaces extensively to define contracts between components and modules
  • Keep types simple and declarative rather than overly nested
  • Use utility types like Partial and Required to reduce duplication
  • Prefer composition with generics over deep inheritance
  • Namespace utility functions to group common logic
  • Use module paths for cleaner imports between local directories
  • Enable strict compiler flags for best type checking
  • Use ts-ignore comments judiciously - not to hide real errors
  • Add /// comments to declare module dependencies
  • Use types for Redux state slices and action creators
  • Create a shared typings file for custom types
  • Document types with JSDoc annotations

And for organizing a TypeScript project:

  • Put shared types in /types folder
  • Group components in /components folder
  • Type declaration files alongside source .d.ts

Following best practices will really allow your TypeScript codebase to scale elegantly.

A few key principles to remember are:

  • Favor simplicity, readability and consistency
  • Use types to incrementally make code safer
  • Don't over-engineer or overuse advanced features

Adopting TypeScript doesn't have to be all-or-nothing. Integrate it slowly into critical parts of your codebase and let it grow from there.

TypeScript with GraphQL

Using TypeScript with GraphQL helps make queries, mutations, and schema definitions strongly typed. For example:

const GET_USER = gql`
  query GetUser($id: ID!) {
    user(id: $id) {
      name
      age
    }
  } 
`

function getUser(id: string) {
  return client.request<{user: {name: string, age: number}}>(GET_USER, { id })
}
Enter fullscreen mode Exit fullscreen mode

Here the query is typed so the response user shape is known.

Additional TypeScript

Recursive Types

We can create recursive types to define nested structures:

type Tree = {
  value: number;
  children: Tree[];
}

const myTree: Tree = {
  value: 1, 
  children: [{
    value: 2,
    children: []  
  }]
}
Enter fullscreen mode Exit fullscreen mode

The Tree type references itself to enable nesting.

Conditional Types

These allow types to depend on a condition:

type MyType<T> = 
  T extends string ? string :
  T extends number ? number :
  any;
Enter fullscreen mode Exit fullscreen mode

Here MyType will be a string if passed a string, number if passed a number, etc.

Mapped Types

These generate new object types based on existing types:

type MyMappedType = {
  [P in keyof User]: User[P]
}
Enter fullscreen mode Exit fullscreen mode

Type Guards

These allow you to narrow down types within a conditional block:

function doSomething(x: number | string) {
  if (typeof x === 'string') {
    // x is string here
  } else {
    // x is number here
  }
}
Enter fullscreen mode Exit fullscreen mode

Intersection Types

Combine multiple types into one:

interface ErrorHandling {
  success: boolean;
  error?: { message: string }  
}

interface ArtworkData {
  id: number;
  title: string;
}

type ArtworkResponse = ArtworkData & ErrorHandling;
Enter fullscreen mode Exit fullscreen mode

Polymorphic Components

Components that accept generic prop types:

interface ButtonProps<T> {
  kind: T;
}

function Button<T extends string>(props: ButtonProps<T>) {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Template Literal Types
Generate types based on strings:

type Message = 'Hello ' & string
const m: Message = 'Hello World' // ok
Enter fullscreen mode Exit fullscreen mode

TypeScript with Testing

TypeScript can help catch errors in tests. For example:

// Component.test.tsx

import {render} from 'test-utils';
import {Component} from './Component';

it('renders correctly', () => {
  const {getByText} = render(<Component />);
  expect(getByText('Hello World').toBeInTheDocument()); 
});
Enter fullscreen mode Exit fullscreen mode

Here TypeScript ensures getByText is called properly.

Declaration Merging

Allow combining declarations from multiple files:

// utils.ts
declare function log(msg: string): void;

// app.ts 
function log(msg: string) {
  console.log(msg);
} 
Enter fullscreen mode Exit fullscreen mode

The implementation is merged with the declaration.

Mixins

Reusable classes that can be combined with components:

class FlyingMixin implements Fly {
  fly() {
    console.log('Flying!');
  }
}

class Bird extends FlyingMixin {}
const b = new Bird();
b.fly();
Enter fullscreen mode Exit fullscreen mode

Conclusion

Here is a summary of the key points we covered in this comprehensive TypeScript guide:

Introduction

  • TypeScript is a typed superset of JavaScript that compiles to plain JavaScript
  • It can prevent many bugs through static type checking
  • Provides features like classes, generics, and enums
  • Can be adopted incrementally in JS projects

Basics

  • Install TypeScript compiler
  • Add types through annotations like :string and :number
  • Interfaces define object shapes like functions and classes
  • Built-in types like string, number, boolean, array
  • Compile to JS using tsc

Classes

  • Define encapsulated class properties and methods
  • Inherit from base classes using extends
  • Access modifiers like public and private
  • Constructor requires all properties be initialized

Generics

  • Create reusable components that work with any type
  • Used in functions, classes, and interfaces

Enums

  • Named constants that enumerate a set of values
  • Useful for states, access levels, etc

Utility Types

  • Help reduce duplication like Partial and Required
  • Provide type safety with Pick and Record

Namespaces & Modules

  • Namespaces wrap code in a logical group
  • Modules use explicit import and export

React + TS

  • Type check props, state, and components
  • Catch bugs in rendering and lifecycle methods

Node + TS

  • Adds types for routes, middleware, and configs
  • Better editor tooling

Best Practices

  • Use interfaces for contracts
  • Prefer composition over inheritance
  • Enable strict compiler flags
  • Keep types as simple as possible

Advanced Topics

  • Type declarations, mapped types, conditional types
  • Testing, meta-programming, mixins

The key is starting with the core features like interfaces and utility types. Adopt incrementally for maximum benefit. Let me know if you have any other questions!

If you like my article, please follow me on JSDevJournal

Top comments (0)