p:: Programming Language, JavaScript f:: CLI, Web Apps

Typing in JavaScript

Javascript Types

Six primitive data types in JavaScript

  1. Boolean
  2. Number
  3. BigInt
  4. String
  5. Symbol
  6. undefined and null

1. Boolean

either true or false

typeof true; // "boolean"

In JavaScript, some values of different types are considered “falsy”, which means if you were to cast the value into a Boolean using two NOT operators or with the Boolean() built-in function, it would become false, where every other value would become true.

2. Number

Numbers in JavaScript are stored as floats, which means you use the same type for whole integers and decimals. JavaScript also has values for Infinity-Infinity, and NaN, or “Not a Number”.

let imaginaryNumber = Math.sqrt(-1); // NaN
 
typeof imaginaryNumber; // "number"
imaginaryNumber === NaN; // false
 
Number.isNaN(imaginaryNumber); // true
Boolean(0); // false
Boolean(NaN) // false

3. BigInt

very large integers

typeof 1337n; // "bigint"
1337n === 1337; // false
Boolean(0n); // false

4. String

let fruitName = "Banana";
typeof fruitName; // "string"
Boolean(""); // false

5. Symbol

unique and immutable

let symbol1 = Symbol("Apple");
let symbol2 = Symbol("Apple");
 
symbol1 === symbol2; // false
 
typeof symbol1; // "symbol"

6. undefined & null

undefined represents uninitialized. null represents the absence of value.

typeof undefined; // "undefined"
 
let nullish = null;
typeof nullish; // "object"
nullish === null; //true
Boolean(undefined); // false
Boolean(null); // false

JavaScript also has structural types, which means we can construct different shapes of the primitive types to represent more complex data structures.

Object

All of the other types we’ve looked at so far can be directly compared by value, but objects (and object-like values) are compared by reference. This means two objects that have the same shape aren’t considered equal.

let car = {
  wheels: 4,
  color: "red",
};
let car2 = {
  wheels: 4,
  color: "red",
};
 
car === car2; // false
 
let carClone = car;
car === carClone; // true
 
typeof car; // "object"

Array

Arrays store data, but use Numbers instead of Strings for indexing properties. This means that the data stored in Arrays is ordered.

let myArray = [1, 2, 3];
 
typeof myArray; // "object"
Array.isArray(myArray); // true

Function

typeof function () {}; // "function"
typeof (() => {}); // "function

Classes

class Car {
  constructor(wheels, color) {
    this.wheels = wheels;
    this.color = color;
  }
}
 
typeof Car; // "function"
 
let motorcycle = new Car(2, "black");
typeof motorcycle; // "object"
 
motorcycle instanceof Car; // true

Special TypeScript Types

any and unknown types

any gives you the greatest flexibility, but absolutely no type safety, so it’s best to avoid any unless it is absolutely necessary.

we can convince TypeScript that an unknown or any value actually has a more specific type by using a process called type narrowing. This involves doing runtime checks which either prove that a value is a specific type or prove that it is not a specific type.

const unknownNumber: unknown = 27;
 
let theAnswer: number = 0;
if (typeof unknownNumber === "number") {
  theAnswer = 15 + unknownNumber;
}

As a general rule of thumb, prefer unknown over any and use type narrowing to determine a more accurate type.

Interfaces

interface EdibleThing {
  name: string;
  color: string;
}
 
interface Fruit extends EdibleThing {
  sweetness: number;
}
 
const apple: Fruit = { name: "apple", color: "red", sweetness: 80 };

Indexable Types

We can mix index signatures with regular property signatures, so long as the key and value types match.

interface Fruit {
  [key: string]: string;
  name: string;
}
 
let apple: Fruit = {
  name: "Apple",
  ripeness: "overripe",
};

Enums

enum Seasons {
  winter,
  spring,
  summer,
  autumn,
}
 
function seasonsGreetings(season: Seasons) {
  if ((season = Seasons.winter)) return "⛄️";
  // ...
}
 
const greeting = seasonsGreetings(Seasons.winter);

Notice that we are able to use Seasons as both a type and a value.

Tuple

Tuples are fixed-length arrays. We can tell TypeScript how many items are in the array, and what the type of each item is.

function simpleUseState(
  initialState: string,
): [string, (newState: string) => void] {
  // The rest of the implementation goes here.
}

TypeScript will never infer an array of items to be a tuple, even if the items are of different types.

Void and Never

void

void represents the absence of any type. It’s often used to indicate that a function doesn’t return anything.

function performRequest(requestCallback: () => void) {
  // implementation goes here
}

never

never represents a value that can never occur. If we every try to access or modify a never variable, TypeScript will give us a warning.

function exception(): never {
  throw new Error("Something terrible has happened");
}
 
const output = exception();
// const output: never;
 
function loopForever(): never {
  while (true) {}
}
 
const loopOutput = loopForever();
// const loopOutput: never;

type aliases

type StringTree = {
  value: string;
  left?: StringTree;
  right?: StringTree;
};
 
let myStringTree: StringTree = getStringTree();
myStringTree.value; // string
myStringTree?.left?.right?.left?.value; // string | undefined

Interface or type?

Interfaces support extension using the extends keyword, which allows an Interface to adopt all of the properties of another Interface. Because of this, Interfaces are most useful when you have hierarchies of type annotations, with one extending from another.

type aliases, on the other hand, can represent any type, including functions and Interfaces!

Union Types

interface CoordinateInterface {
  x: number;
  y: number;
}
type CoordinateTuple = [number, number];
 
type Coordinate = CoordinateInterface | CoordinateTuple;

Intersection Types

interface Fruit {
  name: string;
  sweetness: number;
}
interface Vegetable {
  name: string;
  hasSeeds: boolean;
}
 
type EdibleThing = Fruit & Vegetable;
 
let apple: EdibleThing = {
  name: "Apple",
  sweetness: 80,
  hasSeeds: true,
}; // This works

Combining primitives will always yield a never type or a type that is impossible to satisfy.

type Strnumbering = string & number; // type Strnumbering = never;

Literal Types

type Seasons = "spring" | "summer" | "autumn" | "winter";
 
function seasonMessage(season: Seasons) {
  if (season === "summer") {
    return "The best season.";
  }
  return "It's alright, I guess.";
}
 
seasonMessage("autumn"); // It's alright, I guess.
seasonMessage("fall"); // Type Error: Argument of type '"fall"' is not assignable to parameter of type 'Seasons'.

Classes

Class Definition

class EdibleThing {
  name: string;
  color: string;
  constructor(name: string, color: string) {
    this.name = name;
    this.color = color;
  }
}
 
class Fruit extends EdibleThing {
  sweetness: number;
  constructor(name: string, color: string, sweetness: number) {
    super(name, color);
    this.sweetness = sweetness;
  }
}
typeof Fruit; // "function"
const apple = Fruit("Apple", "red", 80); // Type Error: Value of type 'typeof Fruit' is not callable. Did you mean to include 'new'?
const banana = new Fruit("Banana", "yellow", 70); // This works

Class Modifiers

The three class modifiers are publicprivate, and protected.

readonly is another modifier which is not limited to classes. You can think of this as the const variable declaration, but for object properties.

Property Shorthand

TypeScript gives us a shorthand when our constructor parameters are the same as our properties.

class Fruit {
  constructor(public name: string, protected sweetness: number) {}
}
const apple = new Fruit("Apple", 80);
console.log(apple); // Fruit { name: "Apple", sweetness: 80 }

Accessors (get and set)

class Fruit {
  constructor(protected storedName: string) {}
  set name(nameInput: string) {
    this.storedName = nameInput;
  }
  get name() {
    return (
      this.storedName[0].toUpperCase() + this.storedName.slice(1)
    );
  }
}
 
const apple = new Fruit("apple");
apple.name; // "Apple"
apple.name = "banana";
apple.name; // "Banana"

Notice that I store the name on a different property. If I were to try mutating this.name within my set name() method, it would call set name() again, creating an infinite recursive function.

ES Private Fields

class Fruit {
  #name: string;
  constructor(name: string) {
    this.#name = name;
  }
  get name() {
    return this.#name[0].toUpperCase() + this.#name.slice(1);
  }
  set name(name: string) {
    this.#name = name;
  }
}
const apple = new Fruit("apple");
console.log(apple.name); // "Apple"
apple.#name = "banana"; // Type Error: Property '#name' is not accessible outside class 'Fruit' because it has a private identifier.

private or #private?

ES Private Fields provide “hard privacy”, which means an outside viewer can’t see those properties even if they wanted to.

As a rule of thumb, ES Private fields are generally what you want to use. However, if you can’t target ES Next when you compile your code, or if you can’t afford to have a lot of polyfill code, TypeScript’s private modifier is a good alternative.

Advanced TypeScript Types

TypeScript Operators

Type Indexed Access

interface Fruit {
  name: string;
  color: string;
  nutrition: { name: string; amount: number }[];
}
 
type FruitNutritionList = Fruit["nutrition"];
 
type NutritionFact = Fruit["nutrition"][0];
 
// Alternatively
type NutritionFact = Fruit["nutrition"][number];

typeof

let rectangle = { width: 100, height: 200 };
type RectangleProperties = keyof typeof rectangle; // type RectangleProperties = "width" | "height"

const assertions

let rectangle = { width: 100, height: 100 as const };
// let rectangle: {
//     width: number;
//     height: 100;
// }
 
let message = "Hello" as const;
// let message: "Hello"
 
const assortedItems = ["hello", 5, (fruit: Fruit) => {}] as const;
// const assortedItems: readonly ["hello", 5, (fruit: Fruit) => void]

Type Narrowing

Common Type Guards

Primitive types

function sayNameLoud(name: unknown) {
  if (typeof name === "string") {
    // name is now definitely a string
    console.log(`Hey, ${name.toUpperCase()}`);
  }
}

Arrays

function combineList(list: unknown): any {
  if (Array.isArray(list)) {
    // This will filter any items which are not numbers.
    const filteredList: number[] = list.filter((item) => {
      if (typeof item !== "number") return false;
      return true;
    });
 
    // This will transform any items into numbers, and turn `NaN`s into 0
    const mappedList: number[] = list.map((item) => {
      const numberValue = parseFloat(item);
      if (isNaN(numberValue)) return 0;
      return numberValue;
    });
 
    // This does the same thing as the filter, but with a for loop
    let loopedList: number[] = [];
    for (let item of list) {
      if (typeof item === "number") {
        loopedList.push(item);
      }
    }
  }
}

Classes

instanceof operator

class Fruit {
  constructor(public name: string) {}
  eat() {
    console.log(`Mmm. ${this.name}s.`);
  }
}
 
function eatFruit(fruit: unknown) {
  if (fruit instanceof Fruit) {
    fruit.eat();
  }
}

Objects

Using object instead of unknown will tell TypeScript to let us attempt to access properties on this value. We can create a Union of the generic object type and an Interface with the property that we want to access.

interface Person {
  name: string;
}
function sayNameLoud(person: object | Person) {
  if ("name" in person) {
    console.log(`Hey, ${person.name.toUpperCase()}`);
  }
}

Handling null and undefined

Optional Chaining

?.

Optional Chaining only fails if the property is null or undefined; falsy values like 0 and false will pass through correctly.

Nullish Coalescing

?? - only checks if a value is null or undefined. || - checks against falsy values, not just null and undefined.

Non-null Assertion

 !.

Assertion Signatures

keyword as

function buttonEventListener(
  event: string,
  listener: any,
  element: HTMLButtonElement,
) {
  element.addEventListener(event, listener);
}
 
const anchor = document.createElement("a");
buttonEventListener(
  "click",
  () => console.log("Mouse clicked"),
  anchor,
);
// Type Error: Argument of type 'HTMLAnchorElement' is not assignable to parameter of type 'HTMLButtonElement'.
 
buttonEventListener(
  "click",
  () => console.log("Mouse moved"),
  (anchor as HTMLElement) as HTMLButtonElement,
);
// no error

Structural vs Nominal Typing

class Fruit {
  isFruit = true;
  constructor(public name: string) {}
}
class Apple extends Fruit {
  type: "Apple" = "Apple";
  constructor() {
    super("Apple");
  }
}
class Banana extends Fruit {
  type: "Banana" = "Banana";
  constructor() {
    super("Banana");
  }
}

If we didn’t have the type properties, our Apple and Banana types would be indistinguishable, since they all would share the same property: name:string.

All three of these types are equivalent in TypeScript.

class AppleClass {
  type: "Apple";
  name: string;
}
interface AppleInterface {
  type: "Apple";
  name: string;
}
type AppleType = {
  type: "Apple";
  name: string;
};

Other programming languages, like Java and C# use a nominal type system. The word “nominal” refers to the name of the thing, which means if I were to create two classes with an identical structure, but different names, those two classes would be considered different.

We can emulate nominal typing by adding a unique property to our types with a string literal type. This practice is called “branding”, or “tagging”, and is what allowed us to differentiate between the two types in the type system.

We can also create what are called “branded primitives”. This allows us to create primitive values which are only assignable to variables and parameters that match a specific type signature.

type USD = number & { _brand: "USD" };
type EUR = number & { _brand: "EUR" };
 
let income: USD = 10; // Type Error: Type 'number' is not assignable to type 'USD'.
 
let VAT = 10 as EUR;
 
function convertToUSD(input: EUR): USD {
  return (input * 1.18) as USD;
}
 
let VATInUSD = convertToUSD(VAT); // let VATInUSD = USD;

new

APP_NAME=my-app
{
    mkdir -p $APP_NAME
    cd $APP_NAME
 
    npm init -f
 
    volta pin node
    volta pin yarn
 
    yarn add -D typescript ts-node-dev
    yarn add -D eslint prettier husky lint-staged eslint-config-prettier
    yarn add -D jest ts-jest eslint-plugin-jest
    yarn add -D @types/node @types/jest
 
    echo "{ \"singleQuote\": true, \"jsxSingleQuote\": true }" > .prettierrc.json
 
    npx tsc --init
    npx eslint --init
    yarn ts-jest config:init
 
    rm package-lock.json
}
...
 
success: pinned [email protected] (with [email protected]) in package.json
success: pinned [email protected] in package.json
 
...
 
Created a new tsconfig.json with:
 
  target: es2016
  module: commonjs
  strict: true
  esModuleInterop: true
  skipLibCheck: true
  forceConsistentCasingInFileNames: true
 
 
You can learn more at https://aka.ms/tsconfig.json
 
You can also run this command directly using 'npm init @eslint/config'.
Need to install the following packages:
  @eslint/create-config
Ok to proceed? (y) y
✔ How would you like to use ESLint? · style
✔ What type of modules does your project use? · esm
✔ Which framework does your project use? · none
✔ Does your project use TypeScript? · No / Yes
✔ Where does your code run? · node
✔ How would you like to define a style for your project? · guide
✔ Which style guide do you want to follow? · airbnb
✔ What format do you want your config file to be in? · JavaScript
Checking peerDependencies of eslint-config-airbnb-base@latest
The config that you've selected requires the following dependencies:
 
@typescript-eslint/eslint-plugin@latest eslint-config-airbnb-base@latest eslint@^7.32.0 || ^8.2.0 eslint-plugin-import@^2.25.2 @typescript-eslint/parser@latest
✔ Would you like to install them now with npm? · No / Yes
Installing @typescript-eslint/eslint-plugin@latest, eslint-config-airbnb-base@latest, eslint@^7.32.0 || ^8.2.0, eslint-plugin-import@^2.25.2, @typescript-eslint/parser@latest
 
added 62 packages, removed 212 packages, and audited 265 packages in 10s
 
85 packages are looking for funding
  run `npm fund` for details
 
found 0 vulnerabilities
 
...
 

git init

vim .gitignore
{
  git init
  gaa
}

Prettier

ESLint (and other linters)

eslint-config-prettier

  • Turns off all rules that are unnecessary or might conflict with Prettier.
  • Note that this config only turns rules off, so it only makes sense using it together with some other config.
  • Add "prettier" to the "extends" array in your .eslintrc.* file. Make sure to put it last, so it gets the chance to override other configs.
vim .eslintrc.js
extends: ['airbnb-base', 'prettier'],

Git hooks

Add the following to your package.json:

  "lint-staged": {
    "**/*": "prettier --write --ignore-unknown"
  }

Husky

Modern native Git hooks made easy 🐶 woof!

Usage
  • Edit package.json > prepare script
vim package.json
  "scripts": {
    "dev": "ts-node-dev --respawn src/index.ts",
    "test": "jest",
    "prepare": "husky install"
  },
  • Run prepare script once
yarn run prepare
$ husky install
husky - Git hooks installed
✨  Done in 0.13s.
  • Add hooks:
{
  npx husky add .husky/pre-commit "npx lint-staged"
  npx husky add .husky/pre-commit "yarn run test"
}
husky - created .husky/pre-commit
husky - updated .husky/pre-commit

Jest

Jest is a delightful JavaScript Testing Framework with a focus on simplicity.

eslint-plugin-jest

Add jest to the plugins section of your .eslintrc configuration file. You can omit the eslint-plugin- prefix:

vim .eslintrc.js
...
  plugins: ['@typescript-eslint', 'jest'],
...

Tell ESLint about the environment variables provided by Jest by doing { "env": { "jest/globals": true } }

vim .eslintrc.js
...
  env: {
    es2021: true,
    node: true,
    "jest/globals": true,
  },
...

Getting Started

mkdir src tests

Let’s get started by writing a test for a hypothetical function that adds two numbers. First, create a sum.ts file:

vim src/sum.ts
export default function sum(a: number, b: number) {
  return a + b;
}

Then, create a file named sum.test.ts. This will contain our actual test:

vim tests/sum.test.ts
import sum from "../src/sum";
 
test("adds 1 + 2 to equal 3", () => {
  expect(sum(1, 2)).toBe(3);
});

ts-jest

A Jest transformer with source map support that lets you use Jest to test projects written in TypeScript.

yarn ts-jest config:init
Jest configuration written to "jest.config.js".
yarn test
$ jest
 PASS  tests/sum.test.ts
  ✓ adds 1 + 2 to equal 3 (1 ms)
 
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.973 s
Ran all test suites.
✨  Done in 1.64s.

First Commit

{
  gaa
  git commit -m "setup typescript project"
}