Transitioning to TypeScript can be a significant shift for developers accustomed to other programming languages. TypeScript, a superset of JavaScript, offers static typing and other powerful features that can enhance code quality and readability. Here are ten valuable hints to help developers coming from other languages get up to speed with TypeScript's unique capabilities.
1. Structural Typing
That may be not obvious to people used to classes in other languages with strict type checking. In such languages you cannot interchangeably use instances of two different classes, even if these classes have the same structure.
Typescript on the other hand embraces the “duck typing” or “structural typing” paradigm. This means that if two objects have the same shape, Typescript considers them to be of the same type. This approach allows for greater flexibility in code and makes it easier to work with types that share the same structure.
For example we can use objects without explicitly defined types.
type Point = { x: number; y: number;};function logPoint(point: Point) { console.log(`(${point.x}, ${point.y})`);}const point = { x: 12, y: 26 };logPoint(point); type Point = { x: number; y: number;};function logPoint(point: Point) { console.log(`(${point.x}, ${point.y})`);}const point = { x: 12, y: 26 };logPoint(point); type Point = { x: number; y: number;};function logPoint(point: Point) { console.log(`(${point.x}, ${point.y})`);}const point = { x: 12, y: 26 };logPoint(point); type Point = { x: number; y: number;};function logPoint(point: Point) { console.log(`(${point.x}, ${point.y})`);}const point = { x: 12, y: 26 };logPoint(point); Or we can interchangeably use values of two types having the same structure.
type User = { id: number name: string}type UserUpdate = { id: number name: string}function getUser(): User { type User = { id: number name: string}type UserUpdate = { id: number name: string}function getUser(): User { type User = { id: number name: string}type UserUpdate = { id: number name: string}function getUser(): User { type User = { id: number name: string}type UserUpdate = { id: number name: string}function getUser(): User { 2. Use types instead of interfaces
Both type and interface can be used to define shapes of objects or function signatures and most of the time they can be used interchangeably.
The most important difference is that interfaces are not set in stone and anybody can extend an interface. You can even define interface twice in the same file and the result would be merge.
interface User { id: number}interface User { id: number}interface User { id: number}interface User { id: number}Benefits of types over interface:
Most likely you will have to use type syntax in your project anyways to transform types.
For example omit some values.
type User = { id: number name: string address: string}type UserShortInfo = Omit<User, 'address'type User = { id: number name: string address: string}type UserShortInfo = Omit<User, 'address'type User = { id: number name: string address: string}type UserShortInfo = Omit<User, 'address'type User = { id: number name: string address: string}type UserShortInfo = Omit<User, 'address'That is not possible to do with interfaces. If you use types everywhere you will have more consistent syntax across your project.
Types are easier to reason about
Types are defined only once and you cannot change them, meaning you don’t have to worry that somewhere may be some additional type declaration which adds something to it.
Why do we need interfaces then?
You are working on a public library or framework
In such case if you use interfaces, that will allow developers using library to extend it. For example they may create a plugin to your framework and provide additional fields to your types.
Interfaces integrate better with classes
You can make a class implement an interface as in many other languages.
class Cat implements Animal {}class Cat implements Animal {}class Cat implements Animal {}class Cat implements Animal {}But from my experience in most of the modern libraries you don’t use classes at all.
3. Learn Utility Types
Utility types in TypeScript provide powerful ways to transform existing types into new variants, which can be incredibly useful for creating type-safe code without redundancy. Some commonly used utility types include Partial<T>, Readonly<T>, Record<K,T>, and Pick<T,K>. Familiarizing yourself with these can significantly enhance your ability to manipulate and use types. For a comprehensive list and documentation, refer to the TypeScript official documentation on Utility Types at https://www.typescriptlang.org/docs/handbook/utility-types.html
And few examples on what you can do with them
type User = { id: number name: string address: string}type UserUpdate = Partial<User> type User = { id: number name: string address: string}type UserUpdate = Partial<User> type User = { id: number name: string address: string}type UserUpdate = Partial<User> type User = { id: number name: string address: string}type UserUpdate = Partial<User> 4. Use Libraries to Manipulate Objects
Sometimes you need to convert objects of one type to another, there are already few libraries which can make your life easier in such case.
For example they have omit, pick methods which relate quite nicely to Omit and Pick utility types.
For example
import { omit } from 'radash'const fish = { name: 'Bass', weight: 8, source: 'lake', brackish: false}type Fish = typeof fishtype FishSummary = Omit<Fish, 'weight' | 'source'>const summary: FishSummary = omit(fish, ['name', 'source'])import { omit } from 'radash'const fish = { name: 'Bass', weight: 8, source: 'lake', brackish: false}type Fish = typeof fishtype FishSummary = Omit<Fish, 'weight' | 'source'>const summary: FishSummary = omit(fish, ['name', 'source'])import { omit } from 'radash'const fish = { name: 'Bass', weight: 8, source: 'lake', brackish: false}type Fish = typeof fishtype FishSummary = Omit<Fish, 'weight' | 'source'>const summary: FishSummary = omit(fish, ['name', 'source'])import { omit } from 'radash'const fish = { name: 'Bass', weight: 8, source: 'lake', brackish: false}type Fish = typeof fishtype FishSummary = Omit<Fish, 'weight' | 'source'>const summary: FishSummary = omit(fish, ['name', 'source'])5. You Don’t Need to Put Types Everywhere
One common misconception about TypeScript is that developers need to annotate every variable with a type. TypeScript’s type inference is powerful and can often deduce the type of a variable from its usage. Use explicit types when necessary for clarity or to enforce constraints, but also leverage type inference to keep your code clean and readable.
For example
I would advise though to always add explicit type declarations to public modules functions which are not trivial.
export function getUser(userId: number): User { export function getUser(userId: number): User { export function getUser(userId: number): User { export function getUser(userId: number): User { In such case explicit type definition increases readability, since you don’t have to interpret the code to understand what the function does.
6. Literal, Union, and Algebraic Data Types
Literal types allow you to specify exact values that a variable can hold, while union types enable you to say a variable can be one of several types. Combining these two features can create powerful type assertions that enhance code safety and readability, such as defining a variable that can only hold specific string or number values.
type Direction = "up" | "down" | "left" | "right";function move(direction: Direction) { type Direction = "up" | "down" | "left" | "right";function move(direction: Direction) { type Direction = "up" | "down" | "left" | "right";function move(direction: Direction) { type Direction = "up" | "down" | "left" | "right";function move(direction: Direction) { An algebraic data type is a kind of composite type, formed by combining other types. It is probably easier to show with an example.
Lets imagine we have a function which returns either successful result or an error. How can we do that?
type Success<T> = { success: true type Success<T> = { success: true type Success<T> = { success: true type Success<T> = { success: true 7. Type Guards
Sometimes the way to check type is not straightforward and you implement a helper function to do the check.
function isFish(pet: Fish | Bird): boolean { return (pet as Fish).swim !== undefined;}function isFish(pet: Fish | Bird): boolean { return (pet as Fish).swim !== undefined;}function isFish(pet: Fish | Bird): boolean { return (pet as Fish).swim !== undefined;}function isFish(pet: Fish | Bird): boolean { return (pet as Fish).swim !== undefined;}But then your realize this check doesn’t help you much because you still have to cast pet to Fish to use it.
const pet: Fish | Bird = getPet()if (isFish(pet)) { pet.swim() const pet: Fish | Bird = getPet()if (isFish(pet)) { pet.swim() const pet: Fish | Bird = getPet()if (isFish(pet)) { pet.swim() const pet: Fish | Bird = getPet()if (isFish(pet)) { pet.swim() To fix that you need to use type predicates (or type guards). Lets rewrite our helper function
function isFish(pet: Fish | Bird): pet is Fish { return (pet as Fish).swim !== undefined;}function isFish(pet: Fish | Bird): pet is Fish { return (pet as Fish).swim !== undefined;}function isFish(pet: Fish | Bird): pet is Fish { return (pet as Fish).swim !== undefined;}function isFish(pet: Fish | Bird): pet is Fish { return (pet as Fish).swim !== undefined;}Notice that instead of boolean we used pet is Fish for the return type. That way if we return truthy value from the function, the TypeScript will know that our pet is a fish.
Now there is one particular use case where I find this helpful.
For example, lets say we have users, some of them have middle names, and we need to get the list of all middle names and uppercase it
type User = { id: number firstName: string lastName: string middleName?: string type User = { id: number firstName: string lastName: string middleName?: string type User = { id: number firstName: string lastName: string middleName?: string type User = { id: number firstName: string lastName: string middleName?: string Lets define a function with a type predicate to check for defined values
function <T>isDefined(value: T | undefined): value is T { return value !== undefined}const definedOnly = middleNames.filter(isDefined)function <T>isDefined(value: T | undefined): value is T { return value !== undefined}const definedOnly = middleNames.filter(isDefined)function <T>isDefined(value: T | undefined): value is T { return value !== undefined}const definedOnly = middleNames.filter(isDefined)function <T>isDefined(value: T | undefined): value is T { return value !== undefined}const definedOnly = middleNames.filter(isDefined)8. How to Transform Nested Fields types
Lets imagine we have an API which returns a list of object which have another objects inside them. For example
type User = { id: number, name: string, type User = { id: number, name: string, type User = { id: number, name: string, type User = { id: number, name: string, But we don’t want to use dates as string, we want native JavaScript Dates.
So we create a function which transforms users and deserializes dates
function transformUsers(users: User[]) { return users.map(user => ({ ...user, friends: user.friends.map(friend => ({ ...friend, since: new Date(friend.since) })) }))}function transformUsers(users: User[]) { return users.map(user => ({ ...user, friends: user.friends.map(friend => ({ ...friend, since: new Date(friend.since) })) }))}function transformUsers(users: User[]) { return users.map(user => ({ ...user, friends: user.friends.map(friend => ({ ...friend, since: new Date(friend.since) })) }))}function transformUsers(users: User[]) { return users.map(user => ({ ...user, friends: user.friends.map(friend => ({ ...friend, since: new Date(friend.since) })) }))}But how can we create type for such user without copy pasting all the fields?
Lets use some utility types
type Friend = User['friends'][number]
type Friend = User['friends'][number]
type Friend = User['friends'][number]
type Friend = User['friends'][number]
9. Add intermediate type to narrow down type errors
Lets try the following example
type Comment = { message: string createdAt: string}type Post = { title: string content: string createdAt: string comments: Comment[]}type User = { name: string posts: Post[]}export function getUsers(): Promise<User[]> { type Comment = { message: string createdAt: string}type Post = { title: string content: string createdAt: string comments: Comment[]}type User = { name: string posts: Post[]}export function getUsers(): Promise<User[]> { type Comment = { message: string createdAt: string}type Post = { title: string content: string createdAt: string comments: Comment[]}type User = { name: string posts: Post[]}export function getUsers(): Promise<User[]> { type Comment = { message: string createdAt: string}type Post = { title: string content: string createdAt: string comments: Comment[]}type User = { name: string posts: Post[]}export function getUsers(): Promise<User[]> { The error message is quite long, how can we narrow it down?
You can add as SomeType to the intermediate results.
Lets try
export function getUsers(): Promise<User[]> { return Promise.resolve([ { name: 'John Doe', posts: [ { title: 'Hello World', content: 'This is my first post', createdAt: new Date().toISOString(), comments: [ export function getUsers(): Promise<User[]> { return Promise.resolve([ { name: 'John Doe', posts: [ { title: 'Hello World', content: 'This is my first post', createdAt: new Date().toISOString(), comments: [ export function getUsers(): Promise<User[]> { return Promise.resolve([ { name: 'John Doe', posts: [ { title: 'Hello World', content: 'This is my first post', createdAt: new Date().toISOString(), comments: [ export function getUsers(): Promise<User[]> { return Promise.resolve([ { name: 'John Doe', posts: [ { title: 'Hello World', content: 'This is my first post', createdAt: new Date().toISOString(), comments: [ Of course you don’t need to put as everywhere, but that technique may help you identify the error.
10. Prettify type
Kudos to Matt Pocock who introduced this super helpful type, and to https://www.totaltypescript.com who made a great explanation of it here
The Prettify helper is a utility type that takes an object type and makes the hover overlay more readable.
type Prettify<T> = { [K in keyof T]: T[K];} & {};type Prettify<T> = { [K in keyof T]: T[K];} & {};type Prettify<T> = { [K in keyof T]: T[K];} & {};type Prettify<T> = { [K in keyof T]: T[K];} & {};It’s also not globally available in TypeScript — you’ll need to define it yourself using the code above.
Let’s imagine that you’ve got a type with multiple intersections:
type Intersected = { a: string;} & { b: number;} & { c: boolean;};type Intersected = { a: string;} & { b: number;} & { c: boolean;};type Intersected = { a: string;} & { b: number;} & { c: boolean;};type Intersected = { a: string;} & { b: number;} & { c: boolean;};If you hover over Intersected, you'll see the following:
This is a little ugly. But we can wrap it in Prettify to make it more readable:
type Intersected = Prettify< { a: string; } & { b: number; } & { c: boolean; }>;type Intersected = Prettify< { a: string; } & { b: number; } & { c: boolean; }>;type Intersected = Prettify< { a: string; } & { b: number; } & { c: boolean; }>;type Intersected = Prettify< { a: string; } & { b: number; } & { c: boolean; }>;And then on hover you will see much cleaner type information
I hope you’ll find these hints helpful and they will improve your understanding of TypeScript and make your life easier. Of course there is much more than that. Please let me know in the comments if you would like to see more tips & tricks for TypeScript.