Advanced TypeScript

Freediving - scuba diving without scuba diving. The diver feels the law of Archimedes: he displaces a certain amount of water, which pushes him back. Therefore, the first few meters are given the hardest, but then the pressure force of the water column above you begins to help move deeper. This process is reminiscent of learning and diving into TypeScript type systems - it becomes a little easier as you dive. But we must not forget to emerge in time.


Photo from One Ocean One Breath .

Mikhail Bashurov (saitonakamura) - Senior Frontend Engineer at WiseBits, a TypeScript fan and amateur freediver. The analogies of learning TypeScript and diving deep are not accidental. Michael will tell you what discriminated unions are, how to use type inference, why you need nominal compatibility and branding. Hold your breath and dive.

Presentation and links to GitHub with sample code here .

Type of work


The types of sums sound algebraic - let's try to figure out what it is. They are called variant in ReasonML, tagged union in Haskell, and discriminated union in F # and TypeScript.

Wikipedia gives the following definition: "The type of amount is the sum of the types of works."
- Thank you captain!

The definition is utterly correct, but useless, so let's understand. Let's go from the private and start with the type of work.

Suppose we have a type Task. It has two fields: idand who created it.

type Task = { id: number, whoCreated: number }

This type of work is intersection or intersection . This means that we can write the same code as each field individually.

type Task = { id: number } & { whoCreated: number }

In the algebra of propositions, this is called logical multiplication: this is the operation “AND” or “AND”. The type of product is the logical product of these two elements.
An object with a set of fields can be expressed through a logical product.
The type of work is not limited to fields. Imagine that we love Prototype and decided that we are missing leftPad.

type RichString = string & { leftPad: (toLength: number) => string }

Add it to String.prototype. To express in types, we take string and a function that takes the necessary values. The method and string are the intersection.

An association


Union — union — is useful, for example, to represent the type of a variable that sets the width of an element in CSS: a string of 10 pixels or an absolute value. The CSS specification is actually much more complicated, but for simplicity, let's leave it that way.

type Width = string | number

An example is more complicated. Suppose we have a task. It can be in three states: just created, accepted into work and completed.

type InitialTask = { id: number, whoCreated: number }
type InWorkTask = { id: number, whoCreated: number }
type FinishTask = { id: number, whoCreated: number, finishDate: Date }

type Task = InitialTask | InWorkTask | FinishedTask

In the first and second states, the task has idand who created it, and in the third, the completion date appears. Here the union appears - either this or that.

Note . Instead, typeyou can use interface. But there are nuances. The result of union and intersection is always a type. You can express intersection through extends, but union through interfacecannot. It’s typejust shorter.

interfacehelps only to merge declarations. Library types always need to be expressed through interface, because this will allow other developers to expand it with their fields. So typified, for example, jQuery-plugins.

Type compatibility


But there is a problem - in TypeScript, structural type compatibility . This means that if the first and second types have the same set of fields, then these are two of the same type. For example, we have 10 types with different names, but with an identical structure (with the same fields). If we provide any of the 10 types to a function that accepts one of them, TypeScript will not mind.

In Java or C #, on the contrary, a nominal compatibility system . We will write the same 10 classes with identical fields and a function that takes one of them. A function will not accept other classes if they are not direct descendants of the type that the function is intended for.

The structural compatibility issue is addressed by adding status to the field: enumorstring. But sum types are a way to express code more semantically than how it is stored in the database.

For an example we will address to ReasonML. This is how this type looks there.

type Task =
| Initial({ id: int, whoCreated: int })
| InWork({ id: int, whoCreated: int })
| Finished({
        id: int,
        whoCreated: int,
        finshDate: Date
    })

The same fields, except that intinstead number. If you look closely, we note Initial, InWorkand Finished. They are not located on the left, in the type name, but on the right in the definition. This is not just a line in the title, but part of a separate type, so we can distinguish the first from the second.

Life hack . For generic types (like your domain entities) create a file global.d.tsand add all types to it. They will be automatically visible throughout the TypeScript codebase without the need for explicit import. This is convenient for migration because you don’t have to worry about where to place types.

Let's see how to do this, using the example of primitive Redux code.

export const taskReducer = (state, action) = > {
    switch (action.type) {
        case "TASK_FETCH":
            return { isFetching: true }
        case "TASK_SUCCESS":
            return { isFetching: false, task: action.payload }
        case "TASK_FAIL":
            return { isFetching: false, erro: action.payload }
    }
    
    return state
}

Rewrite the code in TypeScript. Let's start by renaming the file - from .jsto .ts. Turn on all state options and the noImplicitAny option. This option finds functions in which the parameter type is not specified, and is useful at the stage of migration.

Typable State: add field isFetching, Task(which can not be) and Error.

type Task = { title: string }

declare module "*.jpg" {
    const url: string
    export default url
}

declare module "*.png" {
    const url: string
    export default url
}

Note. Lifehack peeped in Basarat Ali Syed's TypeScript Deep Dive . It's a bit dated, but still useful for those who want to dive into TypeScript. Read about the current state of TypeScript on the blog of Marius Schulz. He writes about new features and tricks. Also study change logs , there is not only information about updates, but also how to use them.

Remained actions. We will type and declare each of them.

type FetchAction = {
    type: "TASK_FETCH"
}

type SuccessAction = {
    type: "TASK_SUCCESS",
    payload: Task

type FailAction = {
    type: "TASK_FAIL",
    payload: Error
}

type Actions = FetchAction | SuccessAction | FailAction

The field typefor all types is different, it is not just a string, but a specific string - a string literal . This is the very discriminator - the tag by which we distinguish all cases. We got a discriminated union and, for example, we can understand what is in payload.

Update the original Redux code:

export const taskReducer = (state: State, action: Actions): State = > {
    switch (action.type) {
        case "TASK_FETCH":
            return { isFetching: true }
        case "TASK_SUCCESS":
            return { isFetching: false, task: action.payload }
        case "TASK_FAIL":
            return { isFetching: false, error: action.payload }
    }
    
    return state
}

There is danger here if we write string...
type FetchAction = {
    type: string
}
... then everything will break, because it is no longer a discriminator.

In this action, type can be any string at all, and not a specific discriminator. After that, TypeScript will not be able to distinguish one action from another and find errors. Therefore, there should be exactly a string literal. Moreover, we can add this design: type ActionType = Actions["type"].

Three of our options will pop up in the prompts.



If you write ...
type FetchAction = {
    type: string
}
... then the prompts will be simple string, because all the other lines are no longer important.



We have all typed and got the type of amount.

Exhaustive checking


Imagine a hypothetical situation where error handling is not added to the code.

export const taskReducer = (state: State, action: Actions): State = > {
    switch (action.type) {
        case "TASK_FETCH":
            return { isFetching: true }
        case "TASK_SUCCESS":
            return { isFetching: false, task: action.payload }
        //case "TASK_FAIL":
            //return { isFetching: false, error: action.payload }
    }
    
    return state
}

Here exhaustive checking will help us - another useful property such as sum . This is an opportunity to verify that we have processed all possible cases.

Add a variable after the switch statement: const exhaustiveCheck: never = action.

never is an interesting type:

  • if it is the result of a function return, then the function never ends correctly (for example, it always throws an error or is executed endlessly);
  • if it is a type, then this type cannot be created without any extra effort.

Now the compiler will indicate the error “Type 'FailAction' is not assignable to type 'never'”. We have three possible types of actions, of which we have not processed "TASK_FAIL"- this is FailAction. But to never, it is not “assignable”.

Let's add processing "TASK_FAIL", and there will be no more errors. Processed "TASK_FETCH"- returned, processed "TASK_SUCCESS"- returned, processed "TASK_FAIL". When we processed all three functions, what could be the action? Nothing - never.

If you add another action, the compiler will tell you which ones are not processed. This will help if you want to respond to all actions, and if only to selective, then no.

First tip

Try hard.
At first it will be hard to dive: read about the type of sum, then about products, algebraic data types, and so on in the chain. Will have to make an effort.

When we dive deeper, the pressure of the water column above us increases. At a certain depth, the buoyancy force and water pressure above us are balanced. This is a zone of "neutral buoyancy", in which we are in a state of weightlessness. In this state, we can turn to other type systems or languages.

Nominal Type System


In ancient Rome, the inhabitants had three components of the name: surname, name and "nickname". The name itself is "nomen." From this comes the nominal type system - "nominal". It is sometimes useful.

For example, we have such a function code.

export const githubUrl = process.env.GITHUB_URL as string
export const nodeEnv = process.env.NODE_ENV as string

export const fetchStarredRepos = (
    nodeEnv: string,
    githubUrl: string
): Promise<GithubRepo[ ]> => {
    if (nodeEnv = "production") {
        // log call
    }

    // prettier-ignore
    return fetch('$githubUrl}/users/saitonakamura/starred')
        .then(r => r.json());
}

The configuration comes on the API: GITHUB_URL=https://api.github.com. The githubUrlAPI method pulls out the repositories on which we put an asterisk. And if nodeEnv = "production", then it logs this call, for example, for metrics.

For the function we want to develop (UI).

import React, { useState } from "react"

export const StarredRepos = () => {
    const [starredRepos, setStarredRepos] = useState<GithubRepo[ ] | null>(null)

    if (!starredRepos) return <div>Loading…</div>

    return (
        <ul>
            {starredRepos.map(repo => (
                <li key={repo.name}>repo.name}</li>
            ))}
        </ul>
    )
}

type GithubRepo = {
    name: string
}

The function already knows how to display data, and if there is none, then a loader. It remains to add an API call and populate the data.

useEffect(() => {
    fetchStarredRepos(githubUrl, nodeEnv).then(data => setStarredRepos(data))
}, [ ])

But if we run this code, everything will fall. In the developer panel, we find that it is fetchaccessing the address '/users/saitonakamura/starred'- githubUrl has disappeared somewhere. It turns out that all because of the strange design - nodeEnvcomes first. Of course, it may be tempting to change everything, but the function can be used in other places in the code base.

But what if the compiler prompts this in advance? Then you don’t have to go through the entire launch cycle, detecting an error, or searching for the reasons.

Branding


TypeScript has a hack for this — brand types. Create Brand, B(string), Tand two types. We will create the same type for NodeEnv.

type Brand<T, B extends string> = T & { readonly _brand: B }

type GithubUrl = Brand<string, "githubUrl">
export const githubUrl = process.env.GITHUB_URL as GithubUrl

type NodeEnv = Brand<string, "nodeEnv">
export const nodeEnv = process.env.NODE_ENV as NodeEnv

But we get an error.



Now githubUrlit’s nodeEnvimpossible to assign each other, because these are nominal types. Unobtrusive, but nominal. Now we cannot swap them here - we change them in another part of the code.

useEffect(() => {
    fetchStarresRepos(nodeEnv, githubUrl).then(data => setStarredRepos(data))
}, [ ])

Now everything is fine - they got branded primitives . Branding is useful when several arguments (strings, numbers) are encountered. They have a certain semantics (x, y coordinates), and they should not be confused. It is convenient when the compiler tells you that they are confused.

But there are two problems. First, TypeScript does not have native nominal types. But there is hope that it will be resolved, a discussion of this issue is ongoing in the language repository .

The second problem is “as”, there are no guarantees that the GITHUB_URLlink is not broken.

As well as with NODE_ENV. Most likely, we want not just some string, but "production"or "development".

type NodeEnv = Brand<"production" | "development" | "nodeEnv">
export const nodeEnv = process.env.NODE_ENV as NodeEnv

All this needs to be checked. I am referring you to smart designers and Sergey Cherepanov's report, " Designing a Domain with TypeScript in a Functional Style ."

Second and third tips

Be carefull.
Sometimes stop and look around: at other languages, frameworks, type systems. Learn new principles and learn lessons.

When we pass the point of "neutral buoyancy", the water presses harder and pulls us down.
Relax.
Dive deeper and let TypeScript do its job.

What TypeScript can do


TypeScript can print types .

export const createFetch = () => ({
    type: "TASK_FETCH"
})

export const createSuccess = (task: Task) => ({
    type: "TASK_SUCCESS"
    payload: Task
})

export const createFail = (error: Error) => ({
    type: "TASK_FAIL"
    payload: error
})

type FetchAction = {
    type: "TASK_FETCH",

type SuccessAction = {
    type: "TASK_SUCCESS",
    payload: Task

type FailAction = {
    type: "TASK_FAIL",
    payload: Error
}

There is TypeScript for this ReturnType- it gets the return value from the function:
type FetchAction = ReturnType<typeof createFetch>
In it we pass the type of function. We simply cannot write a function: to take a type from a function or variable, we need to write typeof.

We see in the tips type: string.



This is bad - the discriminator will break because there is an object literal.

export const createFetch = () => ({
    type: "TASK_FETCH"
})

When we create an object in JavaScript, it is mutable by default. This means that in an object with a field and a string, we can later change the string to another. Therefore, TypeScript extends a specific string to any string for mutable objects.

We need to help TypeScript somehow. There is as const for this.

export const createFetch = ( ) => ({
    type: "TASK_FETCH" as const
})

Add - string will disappear immediately in the prompts. We can write this not only across the line, but in general the entire literal.

export const createFetch = ( ) => ({
    type: "TASK_FETCH"
} as const)

Then the type (and all fields) will become readonly.

type FetchAction = {
    readonly type: "TASK_FETCH";
}

This is useful because you are unlikely to change the mutability of your action. Therefore, we add as const everywhere.

export const createFetch = () => ({
    type: "TASK_FETCH"
} as const)

export const createSuccess = (task: Task) => ({
    type: "TASK_SUCCESS"
    payload: Task
} as const)

export const createFail = (error: Error) => ({
    type: "TASK_FAIL"
    payload: error
} as const)

type Actions =
    | ReturnType<typeof createFetch>
    | ReturnType<typeof createSuccess>
    | ReturnType<typeof createFail>

type State =
    | { isFetching: true }
    | { isFetching: false; task: Task }
    | { isFetching: false; error: Error }

export const taskReducer = (state: State, action: Actions): State = > {
    switch (action.type) {
        case "TASK_FETCH":
            return { isFetching: true }
        case "TASK_SUCCESS":
            return { isFetching: false, task: action.payload }
        case "TASK_FAIL":
            return { isFetching: false, error: action.payload }
}

  const _exhaustiveCheck: never = action

  return state
}

All actions typing code has been reduced and added as const. TypeScript understood everything else.

TypeScript can output State . It is represented by the union in the code above with three possible states of isFetching: true, false, or Task.

Use type State = ReturnType. TypeScript prompts indicate that there is a circular dependency.



Shorten.

type State = ReturnType<typeof taskReducer>

export const taskReducer = (state, action: Actions) => {
    switch (action.type) {
        case "TASK_FETCH":
            return { isFetching: true }
        case "TASK_SUCCESS":
            return { isFetching: false, task: action.payload }
        case "TASK_FAIL":
            return { isFetching: false, error: action.payload }
}

const _exhaustiveCheck: never = action

return state
}

Statestopped cursing, but now he is any, because we have a cyclical dependence. We type the argument.

type State = ReturnType<typeof taskReducer>

export const taskReducer = (state: { isFetching: true }, action: Actions) => {
    switch (action.type) {
        case "TASK_FETCH":
            return { isFetching: true }
        case "TASK_SUCCESS":
            return { isFetching: false, task: action.payload }
        case "TASK_FAIL":
            return { isFetching: false, error: action.payload }
}

const _exhaustiveCheck: never = action

return state
}

The conclusion is ready.



The conclusion is similar to what we had originally: true, false, Task. There are garbage fields here Error, but with the type undefined- the field seems to be there, but it seems not.

Fourth tip

Do not overwork.
If you relax and dive too deeply, there may not be enough oxygen to go back.

The training also: if you are too immersed in the technology and decide to apply it everywhere, then most likely you will encounter errors, the reasons for which you do not know. This will cause rejection and will no longer want to use static types. Try to evaluate your strength.

How TypeScript Slows Development


It will take some time to support typing. It does not have the best UX - sometimes it gives completely incomprehensible errors, like any other type system, like in Flow or Haskell.
The more expressive the system, the more difficult the error.
The value of the type system is that it provides quick feedback when errors occur. The system will display errors and take less time to find and correct them. If you spend less time correcting errors, then more architectural solutions will receive more attention. Types do not slow down development if you learn to work with them.

++. - , , (25 26 ) - (27 — 10 ).

(5900 ), ++ IT-, .

AvitoTech FrontendConf 2019 . youtube- telegram @FrontendConfChannel, .

All Articles