The Complete 2023 Guide to Learning TypeScript - From Beginner to Advanced
JSDevJournal
Posted on August 27, 2023
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");
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) {
}
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);
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
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!
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
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;
}
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");
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 };
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
}
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
}
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>) {
// ...
}
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'>;
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);
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);
}
// main.ts
import { User, logUser } from './my-lib';
let u = { name: "Jack" };
logUser(u);
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 />
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 }
// ...
}
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');
});
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) => {
// ...
})
This catches errors early like incorrect property types.
For middleware:
const logger = (req: Request, res: Response, next: NextFunction) => {
// ...
}
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 })
}
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: []
}]
}
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;
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]
}
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
}
}
Intersection Types
Combine multiple types into one:
interface ErrorHandling {
success: boolean;
error?: { message: string }
}
interface ArtworkData {
id: number;
title: string;
}
type ArtworkResponse = ArtworkData & ErrorHandling;
Polymorphic Components
Components that accept generic prop types:
interface ButtonProps<T> {
kind: T;
}
function Button<T extends string>(props: ButtonProps<T>) {
// ...
}
Template Literal Types
Generate types based on strings:
type Message = 'Hello ' & string
const m: Message = 'Hello World' // ok
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());
});
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);
}
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();
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
Posted on August 27, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.