Barbarian Meets Coding
barbarianmeetscoding

WebDev, UX & a Pinch of Fantasy

14 minutes readtypescript

TypeScript

TypeScript is a superset of JavaScript that adds type annotations and, thus, static typing on top of JavaScript. Using this type information TypeScript helps you catch error quicker and can enhance your developer experience with advanced features like statement completion, semantic navigation and smart refactorings.

TypeScript 4.0

TypeScript 4.0 comes with a lot of new features:

1. Variadic Tuple Types

For those non familiar with the term variadic here is an explanation:

  • Variadic => indefinite arity
  • Arity => number of arguments in a function
  • Variadic => indefinite arity => takes a arbitrary number of arguments

A Tuple in turn, is a type that represents a finite collection of things, an array with a specific number of elements of a known type. For example:

[1, 2] // is a tuple of two numbers [number, number]
[1, 2, 3] // is a tuple of three numbers [number, number, number]
[1, 2] as const // is a tuple of literal types [1, 2]
['jaime', 'conan'] // is a tuple of two strings [string, string] and so on

Imagine this function

function concat(arr1, arr2) {
  return [...arr1, ...arr2];
}

How would you go about typing it in TypeScript?

We can manually create a bunch of overrides that encode the type of inputs and expected outputs:

function concat(arr1: [], arr2: []): [];
function concat<A>(arr1: [A], arr2: []): [A];
function concat<A, A2>(arr1: [A], arr2: [A2]): [A, A2];
function concat(arr1:any, arr2:any) {
    return [...arr1, ...arr2];
}

Now when we use the function to concatenate different types of tuples we get a new tuple as a result whose type is the combination of concatinating the types of the arguments:

const array = concat([1], []);                 // => [number]
const array2 = concat([1, 'jaime'], []);       // => [number, string]
const array3 = concat([1], ['jaime']);         // => [number, string]

This is great because we get accurate types which we can use for statement completion or compile time checks:

// We get statement completion (intellisense)
array2[1].includes('jaime') // => True.

// When we try to make something dumb we get an error at compile-time.
array2[9999].includes('jaime') 
// => Tuple type '[number, string]' of length '2' has no element at index '9999'.(2493) GOOOOOOOOD!

Although this solution works as we’d expect, it quickly grows out of control as we need to manually encode all possible type combinations:

function concat(arr1: [], arr2: []): [];
function concat<A>(arr1: [A], arr2: []): [A];
function concat<A, A2>(arr1: [A], arr2: [A2]): [A, A2];
function concat<A, B>(arr1: [A, B], arr2: []): [A, B];
function concat<A, B, C>(arr1: [A, B, C], arr2: []): [A, B, C];
function concat<A, B, C, D>(arr1: [A, B, C, D], arr2: []): [A, B, C, D];
function concat<A, B, C, D, E>(arr1: [A, B, C, D, E], arr2: []): [A, B, C, D, E];
function concat<A, B, C, D, E, F>(arr1: [A, B, C, D, E, F], arr2: []): [A, B, C, D, E, F];
function concat(arr1:any, arr2:any) {
    return [...arr1, ...arr2];
}

An altenative prior to TypeScript 4.0 was to create a catch-all function:

// Or we can create a catch all using generics
function concat2<T, U>(arr1: T[], arr2: U[]): Array<T | U> {
    return [...arr1, ...arr2];
}

This saves us a lot of typing at the expense of type accuracy. When we now try to concatenate two arrays the resulting type is an array whose items can be of any of the types of the original arrays:

// But we lose a bunch of information...
// The result is no longer a tuple, but an array of varying types
const array_ = concat2([1], []);                           // => Array<number>
const array2_ = concat2([1, 'jaime'], []);                 // => Array<number | string>
const array3_ = concat2([1], ['jaime']);                   // => Array<number | string>
const array4_ = concat2([1, 'jaime', true], ['jaime']);    // => Array<number | string | boolean>

That means that we no longer get a great statement completion experience:

array2_[1].includes('jaime') // => includes doesn't exist in type number | string

And there’s no error when we try to access an item outside the bounds of the array since it is no longer a tuple:

array2_[9999].includes('jaime') // => includes doesn't exist in type number | string. IT'S OUT OF BOUNDS YOU CRAZY MANIAC!

TypeScript 4.0 allows to get both a concise representation of the concat function while keeping the types accurate and correct:

// This is the good one
function concat3<T extends readonly unknown[], U extends readonly unknown[]>(arr1: [...T], arr2: [...U]): [...T, ...U] {
    return [...arr1, ...arr2];
}

I like to break it down into separate parts to be able to more easily absorb all the type complexity one bit at a time:

// Generic types definition: T and U
function concat3<T extends readonly unknown[], U extends readonly unknown[]>
// Argument types: two variadic tuples [...T] and [...U]
  (arr1: [...T], arr2: [...U])
// Return type: a tuple resulting of to concatenating variadic tuples
  : [...T, ...U] {
    return [...arr1, ...arr2];
}

In this function:

  • The function definition declares two generic types:
    • T is a readonly array. T extends readonly unknown[] is a bounded generic type where T is bounded to any readonly array whose items can be assigned to unknown (all types are assignable to unknown)
    • U is a readonly array like the above
  • The arguments are the variadic tuples [...T] and [...U]. The concrete type of the tuple is unknown until the function is used and will infer the exact type based on the arguments which is passed.
  • The return type [...T, ...U] is a result of concatenating the result of spreading the types T and U. If these are arrays then the result would be an array of <T|U> but if they are tuples the TypeScript compiler can understand the precise composition of the resulting tuple

If we use this new function we see how the resulting types are what we would expect:

const array__ = concat3([1, 2, 3], []);                     // => [number]
const array2__ = concat3([1, 'jaime'], []);                 // => [number, string]
const array3__ = concat3([1], ['jaime']);                   // => [number, string]
const array4__ = concat3([1, 'jaime', true], ['jaime']);    // => [number, string, boolean string]

And we get a great developer experience and type safety:

// We get statement completion (intellisense)
array2__[1].includes('jaime') // => True

// We get errors when we try to access elements outside of the tuple
array2__[9999].includes('jaime') 
// => Tuple type '[number, string]' of length '2' has no element at index '9999'.(2493) GOOOOOOOOD!

In summary, with variadic tuples we can get top notch type safety and still have a concise way to represent the types of the concat function.

Note that if we use arrays instead of tuples when calling concat the resulting type of concatenating will be widened and we’ll lose the type safety we had earlier:

// if we use arrays the types are widened
// this is an array of numbers
const numbers = [1, 2, 3] // => number[]

const array5__ = concat3(numbers, ['jaime']);   // => (string | number)[] or Array<string|number>
// array5__ is not a tuple but an array of (string | number)

array5__[1].includes('jaime') // => includes doesn't exist in type number | string
array5__[9999].includes('jaime') // => includes doesn't exist in type number | string. IT'S OUT OF BOUNDS YOU CRAZY MANIAC!

2. Labeled Tuple Elements

You can now label tuple elements to improve tuple readability. It also helps with function signatures:

class Person{}
// Using Tuples to represent function signatures
type Name = 
   | [name: string, lastName: string]
   | [name: string, middleName: string, lastName: string]
function createPerson(...name: Name) {
    return new Person();
}
createPerson() // => see intellisense

The alternative using function overloading is a little bit more verbose:

function createPerson2(name: string, lastName: string): Person;
function createPerson2(name: string, middleName: string, lastName: string): Person;
function createPerson2(name: string, middleOrLastName: string, lastName?: string) { return new Person();}
createPerson2() // same intellisense

I think this is probably more useful when creating more abstract functions that operate on argument lists. In both of the cases above I may have used probably use an object. This requires more pondering.

3. Class Property Inference from Constructors

TypeScript 4.0 can now use control flow analysis to determine the types of properties in classes when noImplicitAny is enabled.

class Square {
    // Previously: implicit any!
    // Now: inferred to `number`!
    area;
    sideLength;

    constructor(sideLength: number) {
        this.sideLength = sideLength;
        this.area = sideLength ** 2;
    }
}

I still think that it is good to have the type annotation beside the members themselves. It gives you a berter understanding of the types of things at a glance.

Additional Resources


Jaime González García

Written by Jaime González García , dad, husband, software engineer, ux designer, amateur pixel artist, tinkerer and master of the arcane arts. You can also find him on Twitter jabbering about random stuff.Jaime González García