< return home

TypeScript

Strong, static typing for JavaScript

This article was originally published on #dev-a-day.

typescript

If JavaScript is your first foray into the world of programming, you may be somewhat unfamiliar with the concept of variable typing. You've likely come across the topic in passing but haven't given it much thought. If, on the other hand, JS wasn't the first stop on your programming journey and you came from a background in a strongly typed language like Java or C, JS likely feels like a magical playground where anything goes and occasionally breaks for no reason (😉).

Every variable, whether declared via var, let, or const has a type. If you don't believe me, just pop open a console and try out the typeof operator. This operator will return a string describing the type of the variable that follows:

typeof 'foo' === 'string'
typeof 123 === 'number'
typeof () => {} === 'function'
typeof {} === 'object'
typeof false === 'boolean'
typeof undefined === 'undefined'

Who Needs Types?

When performing mundane tasks in JS, you can often completely disregard variable types and everything will just work™. But why? How does this dark magic work? JavaScript performs implicit type coercion, which means that any time an operation requires a particular variable type, it will just treat the variable it gets as though it were that type (though it may not be) and hope that it works.

Let's look at an example. We want to take a string and concatenate another string onto the end of it:

function concat(str1, str2) {
  return str1 + str2;
}

This works great if we pass two strings, but what happens if we pass a number as the second argument?

concat('foo', 'bar'); // foobar
concat('foo', 123); // foo123

It just works! The number 1 is implicitly coerced to a string and then the two strings are concatenated as any two strings would be.

When Types Attack

As with most things that "just work," there are plenty of cases where it doesn't. What happens if we pass two numbers to our concat function?

concat(123, 456); // 579

Huh? It should have logged 123456, right? The + operator performs different for numbers and strings: it adds numbers together and it concatenates strings. Instead of concatenating 123 to 456, it added them together. Here's an incredibly detailed approximation of how JS sees variables and functions:

types

Variables have distinct shapes determined by their type, and functions are essentially just bottomless pits that will fit any sort of variable, and any number of them too - even though we only handle the first 2 parameters in concat, we could call it with as many parameters we like and JS would just shrug and say "OK":

concat('foo', 'bar', 'baz', 'qux', 'quuz'); // foobar

If you're not diligent when writing and testing your code, it's easy to footgun yourself by not understanding the intricacies of JS's type coercion.

Strong, Static Typing

We're professionals and we want to make sure our code runs predictably and reliably. How can we combat JS's weak, dynamic type system? Enter TypeScript! TypeScript (TS for short) is a superset of JavaScript - it's a set of abstractions built on top of JS, which means that anything you can do in vanilla JS will automatically work in TS, but TS adds in some extra stuff you can't do in vanilla JS. In order to run TS, you will either need to compile it to vanilla JS via tsc, transpile it with a bundler like Webpack / ts-loader or Parcel, or run it directly with a tool like ts-node.

For example, let's say we want to write a function that only accepts a single number parameter. JS functions are bottomless pits, though, right? Not anymore! TS lets us restrict functions, both their parameter count and type(s).

typed-parameter

Types may be written explicitly as type annotations, but they will also be statically inferred by TypeScript. This means you often don't have to put in any extra effort and get the advantages of TS's strong type system for free! Here's the function we have illustrated above:

function acceptOneNumber(num: number) {
  // do something with `num` - it's guaranteed to be a number
}

What happens if we try to call a typed function with a variable of the wrong type?

const str: string = 'a string';
acceptOneNumber(str); // Error: variable `str` is not compatible with type `number`

Cool, now we can specify what types we want our function to work with when we write it and then we don't have to worry about accidentally using an incompatible variable type later down the road. To top that off, we can also explicitly declare what type a function will return:

function returnsNothing(): void {}
function returnsANumber(): number {}
function returnsABoolean(): boolean {}

Types, Types, and More Types

Switching to TypeScript may feel like a downside at first, since it's often advantageous to abuse JS's weak typing and have a function accept multiple different variable types. No fear, TypeScript handles this. TS has a few built-in types for common use cases, and allows you to construct your own custom types to fit your exact needs using union types:

function acceptVoid(param: void) {}
acceptVoid(undefined); // this works
acceptVoid(null); // this works too

type NumberOrString = number | string;
function acceptNumberOrString(param: NumberOrString) {}
acceptNumberOrString(123); // this works
acceptNumberOrString('foo'); // this also works

If you're workshopping a block of code and aren't sure exactly what types you want to use, you can use the any type to disable TS's type checking for that variable:

function acceptAnything(param: any) {}
acceptAnything(123); // this works
acceptAnything('foo'); // this works too
acceptAnything(() => {}); // this also works

TS types work great for emulating enums as well. Just create a union type by combining numbers or string literals:

type Color = 'red' | 'blue' | 'green';
type Category = 0 | 1 | 2 | 3;

Some types (you'll often see this with arrays) will accept another type as a parameter to specify information about what the type contains:

const numberArray: Array<number> = [1, 2, 3];

For simple type parameters for arrays, you can use the following shorthand:

const numberArray: number[] = [1, 2, 3];

Pair all this up with a code editor with TS syntax/linting support and you'll very quickly find yourself with a new appreciation for TS. If you're not sure where to start, check out VS Code which has TS support right out of the box.

Interfaces

Before we go, let's look at one more TS feature - interfaces. An interface lets you describe distinct properties about an object's shape and then accept any objects that fit that shape description. Let's look at some object shapes below:

interface-types

Obviously objects don't actually have shapes like those illustrated, but that's representative of something like the following:

const yellow = { oneProng: 'foo' };

const red = { twoProng: 'foo' };

const blue = { twoProng: 'bar' };

Each of these objects could have any number of other properties, but all we care about is whether they have a oneProng or twoProng property. Let's say we want to write a function that only accepts objects with a oneProng property. This is the perfect time to write an interface:

interface OnePronged {
  oneProng: string;
}

interface TwoPronged {
  twoProng: string;
}

function acceptsOnePronged(obj: OnePronged) {}

Here's an illustration of our OnePronged interface:

interfaces-a

Here's another of our TwoPronged interface (note that it accepts either red or blue):

interfaces-b

Interfaces are great because they're one step removed from types. Interfaces will accept a variable explicitly declared to be of that interface or any object that has a compatible signature:

function acceptsTwoPronged(obj: TwoPronged) {}

const a: TwoPronged = { twoProng: 'foo' };

acceptsTwoPronged(a); // this works via explicit declaration
acceptsTwoPronged({ twoProng: 'bar' }); // this works since it has a compatible signature

Interfaces may also provide an index signature, which is used to determine what type you will get when you access a property that isn't explicitly described on the interface or access a property using array access syntax:

interface Indexed {
  foo: string;
  bar: number;
  [key: string]: string | number;
}

const a: Indexed = {
  foo: 'foo',
  bar: 1,
  baz: 'baz',
  quz: 2,
};

typeof a.foo; // 'string', explicit declaration on interface
typeof a.bar; // 'number', explicit declaration on interface
typeof a.baz; // 'string' or 'number', index signature
typeof a.quz; // 'string' or 'number', index signature

Conclusion

TypeScript brings with it a huge supporting community and years of testing and production usage to back it up. It does have a mild learning curve, but as I mentioned earlier using an editor like VS Code will let your editor do most of the intuition for you. Many libraries you're already using either include TS typings internally or provide them via DefinitelyTyped, a community-backed typing repository.

If you've never given it a shot or tried it in the past and didn't stick with it, give it another go. It will integrate seamlessly into your workflow and will help you catch most errors before you ever run your code. After a few weeks of migrating existing projects to TS, I can proudly say that having code work correctly on first run has become a very common occurence.

ramblings by Aaron Ross, otherwise known as superhawk610
> ...
© 2023 all rights reserved
built with Gatsby