DEV Community

loading...
Cover image for (yet another) Introduction to Typescript

(yet another) Introduction to Typescript

wkrueger profile image Willian Krueger Updated on ・14 min read

This aims a reader who already has some modern JS experience and is curious about TS. Special focus is given on presenting how the type system works.

What will we go through:

  • What is typescript for? What typescript isn't. Why. Why not;
  • Set it up as simply as possible;
  • Type system overview;
  • Caveats from someone used to JavaScript;

Index:

(PS: This ended up being a quite lengthy text, but splitting it didn't really seem a cool idea).

Asterisks (*) scattered around the text indicate parts where I admit I may be sacrificing canonical correctness in favor of prose terseness.

1. What does TypeScript do?

Type checking, works like a linter

TypeScript is used as sort of an advanced linter, as it points errors in your code based on the coherence of the data structures present in it. I emphasize the term linter here because type-check errors really don't block your code from being compiled. The errors are just there to provide you hints.

In order to collect those data structures, TS uses inference in your code. TS already knows a lot of type data from plain JS alone, but you can also complement those with extra type annotations.

JavaScript compilation

As type annotations are not understood by JS parsers, source .ts files must be compiled to .js in order to remove those. Typescript itself includes a compiler and nowadays this can also be done with Babel.

The TS language aims to keep aligned with JS and proposals that had reached stage 3 ("surely coming to JS"). TS aims NOT to include extraneous features that are not or won't be part of JS.

So, by writing TS, you are mostly writing a near future version of JS with types. As with Babel, you can then choose which target to compile (how old is the browser or node.js version you wish to support).

Language services

Language service support is a big focus and differential of TypeScript. A language service is a layer which aims to provide editor goodies like tooltips, navigations, completions, refactors, and suggestions, a dozen of small features which actually bring big improvements in developer experience. The opposite case would be a language in which you only get the compiler feedback when you save a file.

As the TS team works in tandem with the VSCode team to provide its JS language service, its editor experience is very refined.

2. What TS is NOT for

Writing OOP-styled code like C# or Java;

As TS is mostly "JS with types", you should just write TS as you would write JS, whatever code style you prefer. As classes are a JS feature, you could already write classy code in plain JS.

Making my code verbose, littered with type annotations;
Force me into writing in OOP style;

Since it is made to fit already existing JS patterns, TS's type system is quite flexible. The type system does not strongly dictate what patterns you should use. This, paired with the heavy use of inference allows for the usual TS code to have a small amount of type annotations.

Due to the nature of static typing, you will eventually need to adapt some dynamic patterns or lean to more functional patterns, but those will be tiny and beneficial changes. More info on that ahead.

Real cons of using TypeScript

Setting up TS in modern frontend projects (webpack-based) used to be a pain. This has changed drastically since the Babel integration came, along with support on popular templates like create-react-app. Community support in this area has now raised a lot, bringing goodies like better library typings.

3. The simplest build possible

Using the TypeScript compiler (tsc) is the most simple way to get started. Probably simpler than any Babel-related setup you've ever used. tsc can be added to your PATH by globally installing TypeScript (npm i -g typescript).

tsc -w main.ts

... generates a main.js file in the same folder with default compiler settings. -w toggles the watch mode.

A simple project

For a project, it is recommended that you install TypeScript locally so that your project is tied to a specific TS version. In VSCode, tsc can be invoked through F1 > Run Build Task. You should also include a link for it in the package.json scripts.

tsc looks for a tsconfig.json file in the same folder. This also allows it to be called without arguments. The tsconfig accepts an overwhelming set of compiler options -- since it mixes compiling and type checking options. Below I'll go through a set of recommended settings.

{
  "compilerOptions": {
    ...
  },
  "include: ["src"]
}
  • include filters which files to compile. This can be a folder or an entry point (every file referenced by that entry point will also be compiled);

I will usually split input and output files in different folders:

|__ built
| |__ index.js
|__ src
| |__ index.ts
|__ tsconfig.json
  • By default tsc outputs to the same folder the source files are. Use "outDir": "built" to fix that;
  "sourceMap": true
  • Sourcemaps allow you to debug directly in the source .ts files.
  "target": "es2017",
  "module": "esnext",
  "esModuleInterop": true

Those 3 are output settings:

  • target dictates how old is the runtime you want to support;
  • module allows for import/export syntax conversion; You'd usually use "esnext" (no conversion*) when using a bundler, or "commonjs" for node;
  • esModuleInterop is an es-modules "quirk" fix;
  "strict": true,
  "noImplicitAny": false,

Type-checking options:

  • strict turns on all of the latest type-checking features (very important);
  • noImplicitAny disables one specially annoying feature with a good trade-off (personal opinion);
  "lib": ["dom", "es2015", "es2017"],
  • libis entirely optional and allows tuning of which global-environment types are available; For instance, the default setting includes "dom", but you'd like to disable "dom" types in a node.js project.

Concluding it, we got:

{
  "compilerOptions": {
    "target": "es2017",
    "module": "esnext",
    "esModuleInterop": true,
    "strict": true,
    "noImplicitAny": false,
    "lib": ["dom", "es2015", "es2017"],
    "outDir": "dist",
    "sourceMap": true
  },
  "include": ["src/index.ts"]
}

4. Types are Spooky (or: How Types Work)

Types live in a separate world set apart from the "concrete variables" world. Think of it as the "upside-down" of types.

If you try to declare both a concrete variable and a type with the same name, they won't clash, since they live in separate worlds.

const x = 0;
type x = number; //this is ok!

Types are declared by either the type or the interface statements. While those constructs may have peculiarities in syntax, just consider they are just ways to declare types. In the end a type will just represent some structure, regardless of which of the 2 statements you used to declare it*.

interface Animal {
  weight: number;
}
// the word "interface" may be misleading.
// In TS, "interface" just means representing a JS object type
// since it is just a JS object, any property type is allowed, not just methods

Types are immutable

You can't ever modify a type, but you can always create a new type based on another existing one;

interface Cat extends Animal {
  isCatnipped: boolean;
}
type MeowingCat = Cat & { meow(): void };
// We have
// - created new types based on existing ones
// - both "extends" and "type intersection (&)" syntaxes ended up performing the
//   same structural operation: adding a new property the type

A purpose in life

The final purpose of a type is to be linked to a concrete "living" variable, so its sins can be checked by the compiler.

const myFatCat: MeowingCat = {
  weight: 2.4,
  iscatnipped: false, //error!!
  meow() {
    performMeow();
  }
};

What if I don't assign a type to a variable?

  • Every variable will always have a type. If I don't explicitly assign a type, the compiler will then infer one from the initial assignment; On VSCode, one can easily check the type of anything by mouse-overing.
const barkingFatCat = {
  ...myFatCat,
  bark() {
    throw Error("bark not found");
  }
};
// will have weight, iscatnipped, meow and bark properties

An important advice about working with typescript: **mouseover everything**. Every variable. Every time. Extensively.

Seriously, you can do a big bit of "debugging" just by carefully inspecting every variable's inferred type.

A lifelong link

  • One variable can only have one type during its whole lifespan. However, you can still create new variables and do casts;

Going the other way

  • The inverse operation -- retrieving a type from a variable -- is possible with the typeof statement. type StrangeCat = typeof barkingFatCat.

5. Mutable code and types

Because of the properties listed above, some patterns that you might be used to in JS may not work well on a static type system. For instance, let's say one would create an object like this:

const person = {};
person.name = "John"; // error!
person.lastName = "Wick";

TS will complain since person is declared by inference to be of type "empty object". Therefore, person can't accept any properties.

There are many ways we could adapt our code to tackle this problem. The most recommended one is: build the final object in one step, composing its parts.

const person2 = {
  name: "John",
  lastName: "Wick"
}; // OK!

Other more verbose way is pre-declaring the object type. This is not ideal though, since we are repeating ourselves.

interface Person {
  name?: string;
  lastName?: string;
}
const person3: Person = {};
person3.name = "John";
person3.lastName = "Wick";

If you are having a hard time typing something, you can always assign a variable to any, disabling all type-checking on it.

const person4: any = {};
person4.name = "John";
person4.last.name = "Wick"; // this won't type-error, even if wrong

On the productive use of any and other loose types

Every time a developer assigns any to a variable, it acknowledges that TS will stop checking it, facing all the consequences this may bring.

While it's not advisable to use any, sometimes it can be hard to correctly set the type of a variable, especially when learning the language - or even when facing its limitations. Using any is not a crime and sometimes is necessary and productive. One should balance between not using any excessively but also not to spend much time trying to fix a type error.

6. Syntax primer: Primitive types

  • All primitive types are referenced in lowercase. number, string, boolean, undefined, null...
  • TS adds a couple of extra lowercase types solely related to its type-checking job: any, unknown, void, never...
  • Arrays can be declared either by something[] or Array<something>;

Be careful: There also exists an uppercase Number type, which is a different thing from lowercase number! Types like Number, String, Boolean refer to the javascript functions that have those names.

Be careful: Both types {} and object refer to an empty object. To declare an object that can receive any property, use Record<string, any>.

Strict nulls

  • Unlike some other languages, types do not implicitly include null;
  • Ex: in Java, any variable can always also be null;
  • In TypeScript a type is declared as nullable through a type union: type X = Something | null | undefined
  • A type can be narrowed as "not null" through control flow analysis. Ex:
const x = 2 as number | null
if (x) {
    console.log(x) // x cannot be null inside this block
}
  • You can tell the compiler to assume a variable is not null with the ! operator;
interface X {
    optional?: { value: number }
}
const instance: X = {}
console.log(instance.optional.value) // TS will show error
console.log(instance.optional!.value) // assume "optional" exists

7. Interfaces vs. Type Aliases

  • Which one to use? Whatever... both declare types! It is complicated.
  • Type aliases can receive other things than objects; Most noticeable exclusive to those are:
    • Type unions and intersections;
    • Conditional types;
  • Interfaces work exclusively with objects (functions are also objects!). Exclusive to interfaces are:
    • The OOPish extends clause, which is somewhat similar to the type intersection of two objects;
    • Declaration merging. When you declare 2 interfaces with the same name, instead of clashing, their properties will merge. (They can still clash if their properties are incompatible, of course);
    • Common use of declaration merging: Add another property to the global DOM's Window declaration.
interface Animal {
    name: string
    isDomestic?: boolean  // optional property, receives type boolean|undefined
    readonly sciName: string  // forbids mutation. Notable sample: react's state
    yell(volume: 1 | 2 | 3 ): void
      //  - types can receive constants (1 | 2 | 3)
      //  - the "void" type is mostly only used in function returns, and
      //    has subtle differences from undefined
    (): void
      // declare this object as "callable" - this is hardly ever used.
    new (): Animal
      // declare this object as "newable" - this is hardly ever used.
}

interface Cat extends Animal {
    isDomestic: true   // narrows down parent's `isDomestic`
    meow(): void;      // additional property
}

// merges with the interface above
interface Cat extends Animal {
    purr(): void
}

Type alias sample below. Almost the same capabilities and syntax.

type SomeCallback = (i: string) => number
type DiscriminatedUnion = { type: 'a', data: number } | { type: 'b', data: string }

type Animal = {
    name: string
    isDomestic?: boolean
    readOnly sciName: string
    yell(volume: 1 | 2 | 3 ): void
    (): void
    new (): Animal
}

type Cat = Animal & {
    isDomestic: true
    meow(): void
}

// declaration merging not possible

8. Class: a creature that spans both worlds

Classes in TypeScript have a few extra features compared to JS classes, mostly related to type-checking.

  • You can declare uninitialized properties on the class body; Those don't generate JS code, they just declare types for checking.
  • If a property is not initialized on the constructor, or directly, TS will complain. You can either declare a property as optional (append ?) or assume it is not null (append !).
class Foo {
    constructor(name: string) {
        this.name = name
    }
    name: string
    hasBar?: string
    certainlyNotNull!: number
}
  • Access modifiers (private, protected and public) are a thing; Yet again, they only serve as hints to the type-checker. A private declared property will still be emitted and visible in JS code.
  • Class fields can be initialized in-body (same as JS, recent-y proposal);
class Foo {
    // ...
    private handleBar() {
        return this.name + (this.hasBar || '')
    }
    init = 2;
}
  • Unique to TS, you can add modifiers to constructor parameters. This will act as a shorthand that copies them to a class property.
class Foo {
    constructor(private name: string) {} // declares a private property "name"
}

Both worlds

The class statement differs from most others are it declares both a variable and a type. This is due to the dual nature of JS/OOP classes (a class actually packs 2 objects inside one definition).

class Foo {}
type X = Foo          // "Foo - the type" will have the INSTANCE type
type Y = typeof Foo   // Y will have the PROTOTYPE type
                      // (when writing typeof, "Foo" refers to the "living foo",
                      // which in turn is the prototype)
type Z = InstanceType<Y>  // the inverse operation
var foo = new Foo()   // "Foo" exists in both worlds;

9. Structural typing and you

In order to determine if two types are assignable, the compiler exhaustively compares all their properties.

This contrasts with nominal typing, which works like:

Two types only are assignable if they were created from the same constructor OR from an explicitly related constructor. (explicitly related usually means: extends or implements).

Given two classes A and B:

class A {
    name
    lastName
}

class B {
    name
    lastName
    age
}

Now let a function require A as input.

function requireA(person: A) {}
requireA(new A()) //ok
requireA(new B()) //ok
requireA({ name: 'Barbra', lastName: 'Streisand' }) //ok
requireA({ name: 'Barbra', lastName: 'Streisand', age: 77 }) //error
  • The function accepted B as input since its properties were considered assignable;
  • This would not be allowed on nominal typing, since it would require B to explicitly extend or implement A;
  • Since we are just comparing properties, just directly passing a conforming object also works;
  • The last line errors because TS applies a special rule which enforces exact properties if the argument is a literal;

10. Control flow analysis

At each instruction, a variable's type may be narrowed according to the current context.

function cfaSample(x: number|string) {
  console.log(x)  // : number|string
  if (typeof x === 'string') {
    console.log(x) // : string
    return x
  }
  return [x] // [number]
} // inferred return type: string|[number]
  • Some expressions (typeof x === 'string') act as "type guards", narrowing the possible types of a variable inside a context (the if statement);
  • x is narrowed from number|string to string inside the if block;
  • x only can by number at the last line, since the if block returns;
  • The function gets an inferred return type corresponding to an union of all return paths;

Discriminated union

  • The type Actions below is called a discriminated union . The property type is used as a tag to filter out which of the union options is valid at the context;
  • At each case line below, action.data has its type narrowed down;
type Actions =
  | { type: "create"; data: { name: string } }
  | { type: "delete"; data: { id: number } }
  | { type: "read"; data: number }

function reducer(action: Actions) {
  switch(action.type) {
    case 'create':
      return createFoo(action.data) // data: {name: string}
    case 'delete':
      return deleteFoo(action.data) // data: {id: number}
    case 'read':
      return readFoo(action.data)   // data: number
  }
}

11. More advanced type syntaxes for another day

(A veryfast reference overview below. Don't worry if you don't understand something, just know that those exist, so you can research later.)

  • Mapped types is a syntax used to declare generic objects.
type GenericObject = {
    requireMe: number
    [k: string]: any
}
// GenericObject CAN have any property and MUST have `requireMe`
  • Mapped types can be used to remap one object type to another, by iterating over its keys.
  • keyof lists all possible keys of an object type as a type union;
type Dummy = {
    a: string
    b: number
}
type Mapped = {
    [k in keyof dummy]: { value: dummy[k] }
}
// wraps Dummy's values into a { value: x } object
  • Properties may me accessed with [""]
type X = Dummy['a'] //will return `string`
  • Conditional types were created to solve a dozen of the type system's limitations. Its name may be misleading. One of the dozen things conditional types can do is to "pick" a type from inside another type expression. For instance:
type Unwrap<T> = T extends Promise<infer R> ? R : never
type X = Unwrap<Promise<number>>  // X will be 'number'
// this sample also uses generics, which we will cover soon
  • The standard type lib includes some auxiliary type aliases like Record and Omit. All of those type aliases are made by composing the features previously shown. You can check all available helpers and its implementation by CTRL+Clicking any of them.
type DummyWithoutA = Omit<Dummy, 'a'>

When you want to dig deeper, I'd strongly recommend checking the Typescript playground samples session.

12.Generics

Roughly saying, generics are types which can receive type parameters. Like every other type related feature shown, it does not emit any extra JavaScript output.

interface GenericInterface<Data> {
    content: Data
}

type FunctionOf<X, Y> = (i: X) => Y

// functions and classes can also receive type parameters.
function makeData<Input>(i: Input) {
    return { data: i }
}

function cantInfer<Output>(i: any): Output {
    return i
}

class GenericClass<Input> {
    constructor(public data: Input) { }
}
  • A type parameter can receive a default type, making it optional.
function hello<X = string>() {
    return {} as any as X
}

Argument inference

  • A generic function will, at first, require that you supply its type parameters;
cantInfer(2) // error
cantInfer<string>(2) //okay
  • If the type parameter has a default value, it is not required;
hello() //ok
hello<Promise>() //ok
  • If type parameters are referenced in function arguments and NO type parameters are passed on call, TS will try to infer them from the arguments;
function makeData<Input>(i: Input) {
    return { data: i }
}
makeData(2) // Input gets inferred to `number`
            // return type is inferred to { data: number }
makeData<string>(2)  // will raise an error since type parameter
                     // and argument are incoherent

Bounded type parameters

  • A type argument can have constraints;
function acceptObject<Input extends { x: number }>(i: Input) {
    return i
}
acceptObject({}) // error, must at least have x
acceptObject({ x: 2, y: 3 }) // ok, and returns { x, y }

13. Modules

TypeScript is made to adapt to JavaScript. And JavaScript itself has had many module systems for different environments and times. Most notably:

  • The browser console "vanilla" environment is module-less. Every imported file lives in the global scope;
  • node.js traditionally uses the "commonjs" module syntax;
  • Modern front-end code built with module bundlers usually use the "es-modules" syntax;

Module-less typescript

  • A TypeScript file is considered module-less if it has no imports or exports;
  • All typescript source files share the same global context. Which is defined at the include entry of the tsconfig;
  • A file can manually include a reference through the addition of the "triple slash directive" at the first line. Shivers from the good-ol-triple-slash-directive-times?
///<reference path=“./path/to/file”/>

Moduleful typescript

  • The TS import syntax comes from the es-module syntax;
  • You can also write some additional syntax not covered by the es-modules:
import express = require("express") // enforce commonjs import
const express = require("express")  // this works BUT 3rd party types won't get imported
import * as express from 'express'
import express from 'express' // only works with "esModuleInterop"
export = { something: 'x' } // "module.exports =" syntax from commonjs

14. 3rd party types

One can usually obtain types from 3rd party libraries through the following means:

  • The library itself publishes .d.ts definitions along with the package, referencing it on the typings key of package.json;
  • Someone publishes types for the library at the DefinitelyTyped repository, available through npm @types/<lib>;
  • There are methods for manually declaring a 3rd party library's types inside the consumer project;

What if the library does not have types?

  • The library will be imported as any but you can continue to use it as-is;
  • If noImplicitAnyis turned on, a declare "library" entry must be declared in a global file;

3rd party typescript types are also used to power JS type completion in VS Code.

Thats it!

And that was only supposed to be an introduction! Thank you!

Recommended links:

On a future chapter maybe:

  • Domain specific things; React + TS? node + TS?
  • Writing type definitions.

Discussion

pic
Editor guide