DEV Community

Serhii
Serhii

Posted on • Originally published at metacognitive.me

Typescript tutorial for Javascript developers

I wrote an article on why to use Typescript if you're still in doubt about whether to use it in your projects. In short, Typescript allows you to write maintainable code. Javascript works well for prototyping but becomes frustrating when you return to a project again. Suddenly, you see blocks of code where you can't understand what kind of data passes there.

In this article, I want to introduce you to Typescript while playing with code. Thus, you see it in action and don't overflow with an unnecessary theory. I recommend playing with the code you'll meet here in Typescript Playground.

Imagine you want to count items, which have the field meta that contains the field countable set to true, in an array. If an item doesn't have meta.countable, we don't count it.

function getAmount (arr: any[]) {
    return arr.filter(item => item.meta.countable === true).length;
}
Enter fullscreen mode Exit fullscreen mode

Typescript array type with anything in there
Why are you using any? It's not OK! It's for an example. Don't use any in such cases. Read more about it later in the article.

We see the any keyword near the arr argument, that's new to us! I guess you already know what it is. This way we tell TS that arr is of any[] type. It literally means any Javascript type in an array. I.e. arr is an array and every item of it is of type any. It safe you from writing such code:

// Argument of type 'string' is not assignable to parameter of type 'any[]'
getAmount('string');

// Argument of type 'number' is not assignable to parameter of type 'any[]'
getAmount(29);
Enter fullscreen mode Exit fullscreen mode

The compiler ensures you should pass exactly what you've pointed out as an arr argument for the getAmount function. What if you need to pass several types, for example, an array and a string? And if arr is a string, then return 0. A weird case, but imagine you work on a legacy system that uses this function in many places, so somewhen you may get a string.

function getAmount (arr: any[] | string) {
    if (typeof arr === 'string') {
        return 0;
    }
    return arr.filter(item => item.meta.countable === true).length;
}

getAmount('55'); // now it's possible to pass a string
getAmount([{ meta: {countable: true} }]);
Enter fullscreen mode Exit fullscreen mode

| means "or". Thus, arr can be an array of anything(any[]) or a string. Refer to this page for more everyday types in Typescript.

The compiler is smart enough to even infer a return type of getAmount.

// function getAmount(arr: any[] | string): number
function getAmount (arr: any[] | string) {
    // because we always return a number
    // 0 or arr.length(filtered
}
Enter fullscreen mode Exit fullscreen mode

Type inferring for a function that always returns a number

Sometimes, Typescript can't infer a type because of ambiguity. Usually, it's a good practice to explicitly indicate a return type of a function.

function getAmount(arr: any[] | string): number {
    // ...
}

// Syntax: don't forget this
// |
function myFunction(arg: any): boolean {/* function body */}
// |
// a type this function returns
Enter fullscreen mode Exit fullscreen mode

Now you know how to write functions and point arguments and return types! In most cases, that's what you need. All other code is still Javascript. With more types. However, let's dive deeper and highlight more complicated cases and what things to avoid.

Someone may pass anything in an array:

function getAmount(arr: any[]): number {
    // ...
}

getAmount([5, "string", {}, () => {}]); // no error
Enter fullscreen mode Exit fullscreen mode

That's not what we expect. TS works well in this case, we specified any[], so what problems? Don't use any if there's no real need for it. It's easier to pass any than describing an advanced type, but that's what Typescript is for. Don't shoot yourself in a foot in the future.

Typescript objects

We may want to replace any[] with object[] and it would work as we pass objects there, right? Correct, but an null and a function are also objects. It's not what we expect either. Don't use object, try to narrow types.

interface Item {
    meta?: {
        countable?: boolean;
    }
}

function getAmount (arr: Item[]) {
    return arr.filter(item => item.meta?.countable === true).length;
}

getAmount([
    {}, {meta: {countable: true}}
]); // 1
Enter fullscreen mode Exit fullscreen mode

Now it works as expected. We specified a separate interface for a possible array element. Interfaces and types allow you to create your own types using basic Typescript types. Some examples:

// is also called "type alias"
type Hash = string;

// interface are "object" types and allow us
// to specify an object immediately
interface Person {
    name: string;
    isOkay: boolean;
};
// it's the same as using a type alias
type Person = {
    name: string;
    isOkay: boolean;
};
Enter fullscreen mode Exit fullscreen mode

Types and interfaces

Let's start implementing a booking tickets service to dive deeper into these types and interfaces. We want to have the possibility to book a ticket for a person.

type Person = {
    name: string;
}

type Ticket = {
    from: string;
    to: string;
    person: Person;
}

function bookTicket (from: string, to: string, person: Person): Ticket {
    // some procesing
    return {
        from,
        to,
        person,
    };
}

bookTicket('Paris', 'Mars', {name: 'Joey'});
Enter fullscreen mode Exit fullscreen mode

The code seems okay. However, we can book a ticket to Mars using the function, but we don't fly to Mars yet. What we may rectify in our code to adjust to reality? We could add validation for from and to fields inside the function, but we also can do this with TypeScript. For example, we could list possible locations we're flying to and from.

type AvailableLocation = 'Paris' | 'Moon' | 'London';
type Person = {
    name: string;
}
type Ticket = {
    from: AvailableLocation;
    to: AvailableLocation;
    person: Person;
}

function bookTicket (from: AvailableLocation, to: AvailableLocation, person: Person): Ticket {
    // some procesing
    return {
        from,
        to,
        person,
    };
}

// Error: Argument of type '"Mars"' is not assignable to parameter of type 'AvailableLocation'
bookTicket('Paris', 'Mars', {name: 'Joey'});
Enter fullscreen mode Exit fullscreen mode

We narrowed possible options for locations. Thus, eliminated cases when we can write code that calls the function with invalid locations like "Mars" or "Andromeda Galaxy". We listed multiple allowed options via "or" operator - Paris | Moon. We might be using enums for this purpose too:

enum Locations {
    Paris,
    Moon,
    London,
}

type Ticket {
    from: Locations;
    to: Locations;
    person: Person;
}

bookTicket(Locations.Paris, Locations.Moon, {name: 'Joey'});
Enter fullscreen mode Exit fullscreen mode

There are differences in using types and enums, I won't cover them this time, but you may refer to this page for the details.

As you might notice, somewhere I used interface for an object type and then declared another one via type. Use what you like more for such cases or use based on your project code guidelines. For more information about the difference, read here.

Using Record to type objects

Sometimes you have generic objects, where a key is always string(and it's always a string, if you want to use other values, use Map instead) and a value is always string too. In this case, you may define its type as follows:

type SomeObject = {
    [key: string]: string;
}

const o: SomeObject = {key: 'string value'}
Enter fullscreen mode Exit fullscreen mode

There's another way to do the same using Record<keyType, valueType>:

type SomeObject = Record<string, string>;
// it means an object with string values, e.g. {who: "me"}
Enter fullscreen mode Exit fullscreen mode

It's something new here: generics, computed types to re-use the existing ones. Let's re-create the Record type:

type Record<Key, Value> = {
    [key: Key]: Value;
}
Enter fullscreen mode Exit fullscreen mode

Thus, if we want to create an object, we don't need to write such signatures every time. So, an object with number values is as simple as:

const obj: Record<string, number> = {level: 40, count: 10};
Enter fullscreen mode Exit fullscreen mode

We may need more complex types, for example, to represent the state of our API requests. Imagine you have a global state where you put all the API data. Thus, you know where to show a loader, when to remove it, and to show relevant data.

type StateItem = {
    isLoading: boolean;
    response: Record<string, unknown> | null;
};
type State = Record<string, StateItem>;

const state: State = {
    getInvoices: {
        isLoading: false,
        response: null,
    },
};
Enter fullscreen mode Exit fullscreen mode

Do you see the inconveniences here? We might narrow a type for state keys: it's a string, but we want to be sure we put valid API request names there. The second thing is the unknown I put for the response(an object with unknown values), yet it's still better than any, because you should determine its type before any processing.

type APIRequest = 'getInvoices' | 'getUsers' | 'getActions';
type BaseResponse = {isOk: boolean};
type GetInvoicesResponse = BaseResponse & {data: string[]};
type GetUsersResponse = BaseResponse & {data: Record<string, string>[]};
type GetActionsResponse = BaseResponse & {data: string[]};
type StateItem = {
    isLoading: boolean;
    response?: GetInvoicesResponse | GetUsersResponse | GetActionsResponse;
};
type State = Record<APIRequest, StateItem>;

// Type is missing the following properties from type 'State': getUsers, getActions
const state: State = {
    getInvoices: {
        isLoading: false,
        response: {isOk: false, data: ['item']},
    },
};
Enter fullscreen mode Exit fullscreen mode

Let's disassemble some pieces of the above:

  1. APIRequest type is a list of possible requests names. Narrowing types are for the better. See the error comment near the state const? Typescript requires you to specify all the requests.
  2. BaseResponse represents a default and basic response, we always know that we receive {isOk: true | false}. Thus, we may prevent code duplication and re-use the type.
  3. We made a type for every request possible.

While it's better than it was before, but we could do even better. The problem with these types is that response is too generic: we may have  GetInvoicesResponse | GetUsersResponse | GetActionsResponse. If there are more requests, there is more ambiguity. Let's employ generics to reduce duplicate code.

type BaseResponse = {isOk: boolean;};
type GetInvoicesResponse = BaseResponse & {data: string[]};
type GetUsersResponse = BaseResponse & {data: Record<string, string>[]};
type GetActionsResponse = BaseResponse & {data: string[]};
type StateItem<Response> = {
    isLoading: boolean;
    response?: Response;
};
type State = {
    getInvoices: StateItem<GetInvoicesResponse>;
    getUsers: StateItem<GetUsersResponse>;
    getActions: StateItem<GetActionsResponse>;
};
Enter fullscreen mode Exit fullscreen mode

It's more readable and safe to specify every request separately, thus there's no need to check state.getInvoices.response on every response type possible.

  1. Don't use any type. Prefer unknown. In such a way, you should be checking the type before doing any further operations with it.
type Obj = Record<string, unknown>;

const o: Obj = {a: 's'};
o.a.toString(); // Object is of type 'unknown'
Enter fullscreen mode Exit fullscreen mode
  1. Prefer Record<string, T> over object, which can be null, any kind of object, a function.  T refers to a generic type.

  2. Narrow types where possible. If it's a few strings you use often, probably they can be combined in one type(see the example about API requests state).

type GoogleEmail = `${string}@gmail.com`; // yet it's still a string

const email1: GoogleEmail = 'my@gmail.com';

// Type '"my@example.com"' is not assignable to type '`${string}@gmail.com`'
const email2: GoogleEmail = 'my@example.com';
Enter fullscreen mode Exit fullscreen mode

It's a new thing here: template types. Any email is a string, but if you can narrow a type, then why not(it's an example, sometimes it's an overhead).

Other use cases you may encounter

Generics in functions

You saw generics, it's a powerful way to re-use the code, the other examples include functions:

type Response<T> = {
    isOk: boolean;
    statusCode: number;
    data: T;
}

async function callAPI<T> (route: string, method: string, body: unknown): Response<T> {
    // it's a pseudo-fetch, the real API differs
    const response = await fetch(route, method, body);
    // some manipulations with data

    return response;
}
Enter fullscreen mode Exit fullscreen mode

So, the syntax is function <name>:<type> (args) {}. You may use T(or other names for a generic, or, a few of them) inside a function too.

Specifying types for readability

Imagine you work a lot with variables that are strings, but it's hard to understand which is what type exactly. For example, when dealing with OAuth tokens.

type AccessToken = string;
type IdToken = string;
Enter fullscreen mode Exit fullscreen mode

Both tokens are JWT strings, but sometimes it's useful to understand the context.

function callProviderEndpoint (token: AccessToken) {}
function decodeUserInfo (token: IdToken) {}
Enter fullscreen mode Exit fullscreen mode

So, the syntax is function <name>:<type> (args) {}. You may use T(or other names for a generic, or, a few of them) inside a function too.

Type assertions

There are cases when you need to cast(transform to for the compiler) a type to another one. For example, when a library method returns object and you know it's not useful, you need a more narrow type. You may write const result = libResult as Record. as allows you to transform a type into a desired one(if it's possible). The easiest cast is for any types: the compiler doesn't know anything about a value, so it trusts you. There are cases when you'd want to cast something into any for compatibility, but often it's laziness to write correct types. Invest type into writing good(corresponding to reality) types.

You may also do casts like follows:

const response = <MyCorrectType>libResponse;
// the same as
const result = libResponse as MyCorrectType;
Enter fullscreen mode Exit fullscreen mode

Some general questions one may ask

Should I learn Typescript?

Definitely. I presume you're already familiar with Javascript, which is simple and fast to prototype. Typescript adds type safety and readability. Your app's logic becomes more predictable. Read more about reasons to use Typescript.

How to learn Typescript?

Read the documentation about the basic types, or this article. Practice the examples by yourself and go code! Install the environment(many frameworks have their already prepared Typescript ecosystem for you to install and use instantly) and make things happen. It's okay you don't understand some things or you're tired of the compiler errors. It's getting easier.

Summary

I didn't cover all the things in the article. The mentioned above should be enough to spark your interest and learn the basics that cover most cases you'll encounter. Learn as you go. The theory might seem to be complicated and boring, it's okay. Write me on Twitter if you need more details or help.

Original

Oldest comments (0)