10 TypeScript Hints

for Developers Coming from Other Languages

by

Anton Fomin

Team Lead, Expert Full-Stack Developer

by

Anton Fomin

Team Lead, Expert Full-Stack Developer

by

Anton Fomin

Team Lead, Expert Full-Stack Developer

Reading Time

9

minutes

published

Mar 17, 2024

share

email icon
x icon
facebook icon
copy icon

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); // Valid in TypeScript due to duck typing

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 {   // ...}function updateUser(update: UserUpdate) {  // ...}const user: User = getUser()user.name = 'Luke'// Because of structural typing, that is fine, // even though the declared type for `user` is User// and updateUser expects UserUpdate typeupdateUser(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}// That is absolutely valid. We extend the existing interfaceinterface User {  name: string}

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'

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 {}

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> // all fields are optionaltype UserShortInfo = Omit<User, 'address'> // omit fields for example in list viewfunction getPoint() {  return {x: 0, y: 0}}type Point = ReturnType<typeof getPoint> // give name to the inferred return type

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'])

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

// TypeScript infers the type of 'message' as 'string'const message = "Hello, TypeScript";// No need to explicitly type 'add' function since it is very simpleconst add = (a: number, b: number) => a + b;

I would advise though to always add explicit type declarations to public modules functions which are not trivial.

export function getUser(userId: number): User {  // make a http request, deserialize it, handle errors}

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) {  // Move logic}move("up"); // Validmove("forward"); // Error: "forward" is not assignable to type '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 // that is literal `true` type declaration, not an assignment  result: T}type Failure<E> = {  success: false  error: E}type Result<E,T> = Failure<E> | Success<T>function updateUser(): Result<string, User> {  if (Math.random() > 0.5) {    return { success: false, error: 'You are unlucky today' }  }  return { success: true, result: {/*...*/} }}const result = updateUser()console.log(result.error) // This is a typescript error, because not in every case there is an errorif (result.success) {  console.log(result.result) // But this works because typescrips is very smart} else {  console.log(result.error)}

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;}

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() // Does not work, TypeScript does not know it is a Fish}

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;}

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 // may be undefined}const users = getUsers()// Array<string | undefined>const middleNames = users.map(user => user.middleName)// The type is still Array<string | undefined>const definedOnly = middleNames.filter(middleName => middleName !== undefined)// TypeScript error, because the type of the array didn't changeconst upperCase = definedOnly.map(middleName => middleName.toUpperCase())

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)// Now TypeScript knows there are no undefined values in the arrayconst upperCase = definedOnly.map(middleName => middleName.toUpperCase())

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,  // the list of friends with the friendship start date as a string  friends: Array<{id: number, since: 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)    }))  }))}

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] // Give an alias to the user friend typetype FriendDeserialized = Omit<Friend, 'since'> & {  since: Date // override since field type}type UserDeserialized = Omit<User, 'friends'> & {  friends: FriendDeserialized[]}

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[]> {  // TypeScript will complain here with  // Type 'Promise<{ name: string; posts: { title: string; content: string; createdAt: string; comments: { message: string; createdAt: Date; }[]; }[]; }[]>' is not assignable to type 'Promise<User[]>'.  // Type '{ name: string; posts: { title: string; content: string; createdAt: string; comments: { message: string; createdAt: Date; }[]; }[]; }[]' is not assignable to type 'User[]'.  return Promise.resolve([    {      name: 'John Doe',      posts: [        {          title: 'Hello World',          content: 'This is my first post',          createdAt: new Date().toISOString(),          comments: [            {              message: 'Great post!',              createdAt: new Date()            },          ],        },      ],    },  ])}

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: [            // And it narrows down the error to this line and makes the error            // much cleaner.            // Conversion of type '{ message: string; createdAt: Date; }' to type 'Comment' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.            // Types of property 'createdAt' are incompatible.            {              message: 'Great post!',              createdAt: new Date()            } as Comment,          ] as Comment[],        } as Post,      ] as Post[],    } as User,  ] as User[])}

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];} & {};

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;};

If you hover over Intersected, you'll see the following:

/** * { a: string; } & { b: number; } & { c: boolean; } */

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;  }>;

And then on hover you will see much cleaner type information

/** * { *   a: string; *   b: number; *   c: boolean; * } */


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.

by

Anton Fomin

Team Lead, Expert Full-Stack Developer

by

Anton Fomin

Team Lead, Expert Full-Stack Developer

by

Anton Fomin

Team Lead, Expert Full-Stack Developer

by

Anton Fomin

Team Lead, Expert Full-Stack Developer

published

Mar 17, 2024

share

email icon
x icon
facebook icon
copy icon

Recent Articles

Recent Articles

Recent Articles

Ready to create

impact?

Ready to create

impact?

Ready to create

impact?