I started using TypeScript at my first programming job in college, but mostly unintentionally. We were building a web application, and like the young college students we were, using the latest and greatest framework seemed like a good idea (it turned out to be a good idea since Google has kept supporting Angular 2+ since then and will likely continue to for a while). This was during the spring of 2017, and Angular’s initial release was on 14 September 2016, so it was pretty new.
One decision that the Angular team made was to use TypeScript early on. The first stable release of TypeScript was on 12 April 2014, but it had been around for about two years before that. Angular’s use of TypeScript as the default language really pushed adoption of TypeScript in the early days, and that was how I encountered it too.
However, my use of TypeScript in that Angular web app was a little questionable.
I knew that I could type stuff, so sometimes I added a let x: number = 4
or
let name: string = 'Kyle'
to feel good about myself. Other things had types
because I copied examples from the official guide. Apart from that, I didn’t
really know what else TypeScript could do. So a little over a year ago, I set
out to read every single page in the TypeScript documentation. I was already
familiar with JavaScript, even before starting that first job, and I wanted to
know what benefits a gradual type system can offer.
In this post, I’ll talk about some things I found to be useful or surprising about TypeScript that I wouldn’t have encountered in the day-to-day work of building the web app.
Two ways to declare an array type
There are two options, one of which may be more familiar to people coming from a
JavaScript background, number[]
, and one that might be more familiar for
people coming from C#/Java, Array<number>
.
Tuples
You can list out the types in an array.
let x: [string, number] = ['a string', 3];
let y: [string, number, ...any] = ['a string', 3, 5, false];
// error, will not compile
let z: [string, number] = ['a string', 3, 5];
The never type
The never type is inferred by the compiler for functions that never return, such
as a function with an infinite loop. This one is kind of weird and I definitely
hadn’t seen it before in any other language. Marius Schulz has an
article describing
the never
type. I’ve never seen it used in production code, though.
This will compile:
function foo(): never {
while (true) {
console.log(1);
}
}
But this will not. The compiler gives the error “A function returning ’never' cannot have a reachable end point”.
function foo(): never {
console.log(1);
}
Whether or not it compiles, using never
in your code is a probably a sign that
something is terribly wrong.
Immutability
const
does not imply immutability. However, there are some features of
TypeScript that you can use to create immutable data structures.
interface Rectangle {
readonly height: number,
readonly width: number,
}
const rect: Rectangle = {
height: 5,
width: 2,
}
// error: cannot assign to 'height' because it is a read-only property
rect.height = 5;
It’s also possible to make arrays immutable with ReadonlyArray<T>
.
const fruits: ReadonlyArray<string> = ['apple', 'blueberry'];
// error: index signature in type 'readonly string[]' only permits reading
fruits[1] = 'pineapple';
Function interfaces
Interfaces in TypeScript do a lot more compared to interfaces in Java, where in Java interfaces specify the behavior that classes must implement. In TypeScript, the term interface is used a lot more loosely and can specify the shape of a class, object, or function.
In the example below, I create an interface bookFlight
that describes the
types that flight booking functions should have.
interface Flight {
// ...
}
interface bookFlight {
(seat: string, flightNum: number): Flight;
}
const bookDomesticFlight: bookFlight = (seat, flightNum) => {
return {
// ...
};
}
const bookInternationalFlight: bookFlight = (seat, flightNum) => {
return {
// ...
};
}
// error: type '(seat: string, flightNum: string) => {}' is not assignable to type 'bookFlight'
const bookBadFlight: bookFlight = (seat: number, flightNum) => {
return {
// ...
};
}
The two functions bookDomesticFlight
and bookInternationalFlight
are typed
even though they don’t have any types after the parameters. Giving the function
name itself a type of bookFlight
will ensure that the parameters are type
checked when they are used.
In the last function, bookBadFlight
, I said that the type of seat
is
number
, but that doesn’t match the interface, so the compiler gave an error.
The same idea applies to the return type, though in the above example I didn’t
go into detail about what the shape of Flight
is.
Interfaces can extend a lot
Interfaces can extend one or more interfaces.
interface Flight extends FlightDetails, FlightSeating {
// ...
}
Interfaces can extend classes.
interface Flight extends FlightClass {
// ...
}
Spread parameters can be typed
function foo(teamCaptain: string, ...players: string[]) {
console.log('Team captain is: ' + teamCaptain);
players.map(name => console.log(name));
}
foo('Sam', 'Sally', 'Sue');
In the above example, if I were to pass in a number for one of the player names, the compiler would not allow it.
const enum
TypeScript has enums, and one fun fact is that when you declare an enum as
const enum
instead of just enum
, the compiler will inline the uses of the
enum at each use site. This results in less indirection at run time.
const enum Sign {
Positive,
Negative,
}
let signs = [
Sign.Positive,
Sign.Negative,
];
The generated JavaScript is:
"use strict";
let directions = [
0 /* Positive */,
1 /* Negative */,
];
Intersection types
A type can be the intersection of multiple types. In the below example, our
rect
is of type Rectangle & Drawable
, which means that it must have the
properties of both a Rectangle
and a Drawable
.
interface Rectangle {
readonly height: number,
readonly width: number,
}
interface Drawable {
readonly lineWidth: number,
}
const rect: Rectangle & Drawable = {
height: 5,
width: 2,
lineWidth: 6,
}
Record<Keys, Type>
This type requires that an object have all the keys Keys
and that the value
for each key is of type Type
.
type Gate = "A1" | "A2" | "B1";
interface FlightInfo {
departure: string,
arrival: string,
}
const currentFlights: Record<Gate, FlightInfo> = {
A1: { departure: "12:00", arrival: "2:00" },
A2: { departure: "11:00", arrival: "12:00" },
B1: { departure: "5:00", arrival: "7:00" },
}
Pick<Type, Keys>
I actually wrote a blog post about this one! Pick<Type, Keys> in TypeScript
Conclusion
Although I was “using” TypeScript when making that Angular web app, the extent of my knowledge about it was the first couple pages of the documentation and whatever hints VS Code would suggest. After going through the official documentation, I feel much more comfortable using the advanced features of TypeScript to describe my software at compile time. Having worked in JavaScript-only projects with small to medium sized teams, it can get a little tiring to see a rather ambiguous name in the signature of a function and then have to guess as to what it can or cannot accept. In some cases, this dynamic nature is preferable, but in others, where objects must conform to certain shapes in order to be accepted further downstream, it can lead to slower development time and production bugs.
Another benefit to knowing about all the different types possible with TypeScript is that I can now read declaration files of other libraries and not be surprised by confusing syntax. There are a number of times while I was using Stripe’s JavaScript SDK that their type declaration files came in handy when I wanted to find out what a function can accept as arguments, without having to open a web browser and go to their SDK documentation.
If you want to try out the examples in this article, there’s a TypeScript playground where you get a lot of real-time feedback on what is happening as the TypeScript compiler checks your code.