Typed Ember extends Confidence Part 1: What is TypeScript?
This article is part 1 of a series on converting your Ember app to TypeScript to foster confidence in your engineering team, based on my talk for EmberConf 2021.
We're going to start with some basics: "What even is a type? What is TypeScript?" Then, we'll look at what TypeScript looks like in an Ember app before circling back to the benefits of TypeScript in the context of developer confidence.
What is a Type?
You've likely come across the concept of "types" before. A value's type tells us what kind of data it stores and what you can and cannot do with that data.
JavaScript Primitive Types
The most basic types are called primitives. You can check a value's primitive type by using typeof
, with the exception of null
(๐๐ We'll come back to this later). Let's take a look at JavaScript's primitive types:
A number can hold a floating-point number (but shouldn't be used for numbers that exceed Number.MAX_SAFE_INTEGER
), Infinity
, or NaN
(not a number).
typeof 1;
//=> 'number'
typeof 1.0;
//=> 'number'
Number.MAX_SAFE_INTEGER;
//=> 9007199254740991
1 / 0;
//=> Infinity
typeof Infinity;
//=> 'number'
typeof NaN;
//=> 'number'
For those values exceeding Number.MAX_SAFE_INTEGER
, "bigint" to the rescue! A bigint can hold integers of arbitrary size (up to a limit determined by the JavaScript implementation).
typeof 9007199254740991n;
//=> 'bigint'
typeof (9007199254740991n ** 9007199254740991n);
//=> Uncaught RangeError: Maximum BigInt size exceeded
A string can hold a sequence of characters to represent text.
typeof 'Hello, EmberConf!';
//=> 'string'
typeof '';
//=> 'string'
A boolean can hold either true
or false
.
typeof true;
//=> 'boolean'
typeof false;
//=> 'boolean'
A symbol is a unique, anonymous value (๐ handwave, ๐ handwave, don't worry too much about this one). (If you know Ruby, this is not the same as Ruby symbols.)
typeof Symbol();
//=> 'symbol'
The undefined value is assigned to variables that have been declared but not yet defined.
typeof undefined;
//=> 'undefined'
let myVariable;
typeof myVariable;
//=> 'undefined'
let myObject = { hello: 'EmberConf!' }
typeof myObject.goodbye;
//=> 'undefined'
And finally, null is a value you can assign to a variable to indicate intentional absence.
typeof null;
//=> 'object'
null === null;
//=> true
As we can see above, typeof null
returns... 'object'
? We've run across the first gotcha in using typeof
to check a type. It turns out that typeof null
returns 'object'
due to how null
was implemented in the very first implementation of JavaScript. Unfortunately, changing this breaks the internet, so we're stuck with it. Because there is only one null
value, you can just check if null === null
.
In JavaScript, primitives are immutable values that are not objects and have no methods. This might sound confusing, because we know that we can create a string and then call methods on it. For example:
let s = 'Hello, EmberConf!';
s.startsWith('Hello');
//=> true
These methods are available becauseโwith the exception of undefined
and null
โthe JavaScript implementation will wrap all primitives in their respective wrapper objects to provide methods. In the example above, we can imagine the following happening under the hood:
// What we type
let s = 'Hello, EmberConf!';
// What the JavaScript implementation does under the hood
let s = new String('Hello, EmberConf!');
//=> String {
// 0: 'H',
// 1: 'e',
// 2: 'l',
// 3: 'l',
// 4: 'o',
// ...
// 16: '!',
// length: 17,
// __proto__: String, <= this is where the methods come from
// }
JavaScript Structural Types
In addition to primitives, JavaScript has more complex structural types:
An object is a mutable collection of properties organized in key-value pairs. Arrays, sets, maps, and other class instances are all objects under the hood. Because typeof
will return the string 'object'
regardless of the class, instanceof
and other checks are more useful here. (๐๐ NOTE: For framework code or when using iFrames, you might not want to use instanceof
either.)
typeof { hello: 'EmberConf!' };
//=> 'object'
typeof ['Hello', 'EmberConf!'];
//=> 'object'
typeof new Set(['Hello', 'EmberConf!']);
//=> 'object'
typeof new Map([['Hello', 'EmberConf!']]);
//=> 'object'
['Hello', 'EmberConf!'] instanceof Array;
//=> true
// preferred
Array.isArray(['Hello', 'EmberConf!']);
//=> true
new Set(['Hello', 'EmberConf!']) instanceof Set;
//=> true
new Map([['Hello', 'EmberConf!']]) instanceof Map;
//=> true
The other structural type is function. A function is a callable object.
function hello(conf = 'EmberConf') { return `Hello, ${conf}!` }
typeof hello;
//=> 'function'
hello();
//=> 'Hello, EmberConf!'
// This is what "callable" means:
hello.call(this, 'RailsConf');
//=> 'Hello, RailsConf!'
// But it's still just an object:
hello.hola = 'Hola!';
hello.hola;
'Hola!'
JavaScript is a loosely typed and dynamic language.
You may have heard that JavaScript is a loosely typed and dynamic language. What does this mean?
In JavaScript, loosely typed means that every variable has a type, but you can't guarantee that the type will stay the same. For example, you can change the type of a variable by assigning a different value to it:
let year = 2021;
typeof year;
//=> 'number'
year = 'two thousand and twenty one';
typeof year;
//=> 'string';
Even stranger, in some instances, JavaScript will implicitly coerce your value to a different type, sometimes in unexpected ways.
2 + 2;
//=> 4
2 + '2';
//=> '22'
2 + [2];
//=> '22'
2 + new Set([2]);
//=> '2[object Set]'
2 + true;
//=> 3
2 + null;
//=> 2
2 + undefined;
//=> NaN
JavaScript is also a dynamically typed language. This means that you never need to specify the type of your variable. Instead, the JavaScript implementation determines the type at run-time, and it will do the best it can with that knowledge at run-time. Sometimes, that means coercion, as we just saw. And sometimes you get...dun dun dun...the dreaded Type Error.
var undef;
undef.eek;
// ๐ฃ TypeError: 'undefined' is not an object
This is fine
Great...so now we know that every variable in JavaScript has a type. We also know that the type of the variable can be changed and that if we use a value improperly, JavaScript will either implicitly coerce it with unexpected results or throw an error.
What could go wrong?
It turns out, quite a bit. In 2018, the error monitoring company Rollbar analyzed their database of JavaScript errors and found that 8 of the top 10 errors were some variation of trying to read or write to an undefined or null value.
Furthermore, because of type coercion, it's possible you might have additional type-related bugs that don't cause errors to be thrown in your app.
All of this leads to uncertainty. And uncertainty is the enemy of confidence.
What is TypeScript?
Fortunately, TypeScript can help!
TypeScript is an extension of JavaScript. When writing TypeScript, you can use all of JavaScript's features, plus additional TypeScript features.
The main difference, syntactically, is that TypeScript adds optional type annotations on top of the JavaScript you already know and love. When the compiler turns your TypeScript into JavaScript, it determines the types of the values in your code and checks the validity of the types in the contexts in which you use them before outputting standard JavaScript (with the type information removed). If you've used a value incorrectly, you get a type-checking error, alerting you to the issue before you ship your code.
Also, because our text editor can run the TypeScript compiler in the background, it can integrate the type information and other related documentation into the editor user experience. For example: code completion, hover info, and error messages. VSCode gives you these features out of the box, no installation required.
And because TypeScript comes with all of JavaScript's built-in types baked in, such as DOM types, you have a ton of information at your fingertips.
It's basically like having an answer key to your code. Having an answer key available to you at all times not only reduces your cognitive overhead, but it makes you feel like a rock star.
Soโฆhow does TypeScript get the answer key?
Because TypeScript is a strictly and statically typed language.
Statically Typed*
(*but also dynamically typed because it compiles to JavaScript!)
Statically typed means that TypeScript checks your code for potential type errors at compile-time. If the TypeScript compiler determines that you have a potential Type Error, it will log a "type-checking error" during compilation. Fix it and no runtime error.
let myVariable = 1;
// Type 'string' is not assignable to type 'number'.
myVariable = 'string cheese';
For all of this magic to work, TypeScript needs to know the type of your values at compile-time. Unlike some other strictly typed languages, TypeScript can sometimes infer the type of a value from its usage. Other times, you may need or prefer to declare the type of a value with a type annotation.
let myVariable: Array<number> = [];
myVariable.push(1);
// Argument of type 'string' is not assignable to parameter of type 'number'.
myVariable.push('string cheese');
It's worth noting that unlike some other statically typed languages, TypeScript will still compile even when you have type-checking errors. There are a couple of implications to this:
- Should a Type Error make it through type-checking, the compiled JavaScript code will still throw the error at run-time.
- If you are converting JavaScript code to TypeScript and you have type errors because your types aren't exactly right yet, you can still go into the console to poke around. I find this strategy super useful to avoid feelings of "fighting the type system" that you might get from other statically typed languages.
In other statically typed languages, the type system can feel like a gatekeeper. In TypeScript, it feels more like a messenger. While type-checking errors can be frustrating at times, they're almost always telling you useful information. "Don't shoot the messenger."
Strict, but not TOO strict
Typescript is more strictly typed than JavaScript.
Once a value has a type, TypeScript will not allow you to change that type (with the exception of undefined
and null
).
Also, TypeScript disallows a lot of implicit type coercion. For example, you can add the number 2
to the number 2
, but you will get type-checking errors if you try to add, for example, an array or a set to the number 2
.
2 + 2;
//=> 4
// Operator '+' cannot be applied to types 'number' and 'number[]'.
2 + [2];
//=> '22'
// Operator '+' cannot be applied to types 'number' and 'Set<number>'.
2 + new Set([2]);
//=> '2[object Set]'
// Operator '+' cannot be applied to types 'number' and 'boolean'.
2 + true;
//=> 3
// Object is possibly 'null'.
2 + null;
//=> 2
// Object is possibly 'undefined'.
2 + undefined;
//=> NaN
Interestingly, TypeScript will still allow you to add a string to a number. The compiled JavaScript will implicitly convert the number to a string before concatenating the two strings. In my example, this might seem ridiculous:
2 + '2';
//=> '22'
Though you might be able to imagine it happening in the case of, say, an input value:
2 + document.querySelector('input[type="number"]').value;
//=> '22'
But in the real world, adding a number to a string is a common-enough thing to do intentionally that it's considered idiomatic (This just means "everyone does it"). For example:
let itemCount = 42;
throw 'Too many items in queue! Item count: ' + itemCount;
//=> Error: Too many items in queue! Item count: 42
The TypeScript team decided not to make TypeScript too strict in this case. If you disagree with them, you can enable an ESLint rule to forbid this:
// Operands of '+' operation must either be both strings or both numbers.
// Consider using a template literal.
// eslint(@typescript-eslint/restrict-plus-operands)
2 + '2';
//=> '22'
This is one example of how TypeScript's strictness is configurable. You can increase or decrease the strictness via a file called tsconfig.json
. Enabling "strict": true
puts you in the strictest mode. And if that's not strict enough for you, you can enable even more checks with the typescript-eslint plugin.
Type Safety
Strictness helps you achieve something called type safety. This means that TypeScript will help you avoid those pesky Type Errors. In fact, in a 2017 paper for the Institute of Electrical and Electronics Engineers convention, researchers found that TypeScript detected 15% of public bugs.
Type safety helps you become a more confident developer.
TypeScript Types
As I mentioned before, the main difference between TypeScript syntax and JavaScript syntax is type annotations. Let's start by revisiting JavaScript's basic types in TypeScript syntax.
Primitive Types
For example, here are examples of explicit type declarations for each of JavaScript's primitives represented in TypeScript.
let myVariable: number = 1;
let myVariable: bigint = 9007199254740991n;
let myVariable: string = 'Hello, EmberConf!';
let myVariable: boolean = true;
let myVariable: symbol = Symbol();
let myVariable: undefined;
let myVariable: null = null;
Of course, as I mentioned before, TypeScript can infer the type of your value from it's usage, so these explicit annotations may not always be necessary:
let myVariable = 1;
let myVariable = 9007199254740991n;
let myVariable = 'Hello, EmberConf!';
let myVariable = true;
let myVariable = Symbol();
let myVariable;
let myVariable = null;
Structural Types
The annotations for structural types start to get a little more complicated. Here are examples of explicit type declarations for different structural types:
The Array
type is an example of a generic typeโa reusable type that takes another type as an argument (denoted with angle brackets). In this case, the Array
type takes string
as an argument, and TypeScript now knows that our variable is an array of strings. (๐๐ NOTE: You can also use the string[]
notation for an array.)
let myVariable: Array<string> = ['Hello', 'EmberConf!'];
To declare the type of a function, declare the type of each variable and the return type, as so:
function sayHello(crowd: string): string {
return `Hello, ${crowd}!`;
}
sayHello('EmberConf');
//=> 'Hello, EmberConf!'
// Argument of type 'number' is not assignable to parameter of type 'string'.
sayHello(1);
//=> 'Hello, 1!'
For an object, use an interface to represent each of the properties and their types:
interface MyObject {
hello: string;
}
let myVariable: MyObject = { hello: 'EmberConf!' };
// Property 'goodbye' does not exist on type 'MyObject'.
myVariable.goodbye;
Moar Types!
In addition to JavaScript's basic types, TypeScript provides additional types. Let's go over a few types you might need to understand the next article in this series:
The unknown type is useful for when you don't know the type of the value. When you use unknown
, you can "narrow" the type of the value using typeof
or other comparisons.
function prettyPrint(raw: unknown): string {
if (typeof raw === 'string') {
// TypeScript now knows that `raw` is a string
return raw;
}
if (Array.isArray(raw)) {
// TypeScript now knows that `raw` is an array
return raw.join(', ');
}
throw '`prettyPrint` not implemented for this type';
}
The any type can also be used when you don't know the type of a value. The difference, though, is that when you annotate a value as any
, TypeScript will allow you to do anything with it. Essentially, when you use the any
type, you are opting out of static type checking for that variable. Proceed with caution! (Fortunately there are tsconfig and eslint-typescript rules to forbid using any
!)
let yolo: any = 'hehehe';
// TypeScript won't yell at you here
yolo = null;
// or here
yolo.meaningOfLife;
//=> TypeError: Cannot read property 'meaningOfLife' of null
And lastly, the void type is the absence of a type. The void
type is most commonly used to specify that we donโt expect this function to return anything:
function sayHello(crowd: string): void {
console.log(`Hello, ${crowd}!`);
}
function sayHello(crowd: string): void {
// Type 'string' is not assignable to type 'void'.
return `Hello, ${crowd}!`;
}
Moving On!
This concludes our TypeScript overview. Now, let's move on to the fun stuff: converting an Ember app to TypeScript!