TypeScript Basics
Each and every value in JavaScript has a set of behaviors you can observe from running different operations.
That sounds abstract, but as a quick example, consider some operations we might run on a variable named foo
.
// accessing the property 'toLowerCase' // on 'foo' and then calling it foo.toLowerCase(); // calling 'foo' foo();Try
If we break this down, the first runnable line of code accesses a property called toLowerCase
and then calls it.
The second one tries to call foo
directly.
But assuming we don't know the value of foo
- and that's pretty common - we can't reliably say what results we'll get from trying to run any of this code.
The behavior of each operation depends entirely on what what value we had in the first place.
Is foo
callable?
Does it have a property called toLowerCase
on it?
And if it does, is toLowerCase
callable?
If all of these values are callable, what do they return?
The answers to these questions are usually things we keep in our heads when we write JavaScript, and we have to hope we got all the details right.
Let's say foo
was defined in the following way.
let foo = "Hello World!";
As you can probably guess, if we try to run foo.toLowerCase()
, we'll get the same string, but completely in lower-case letters.
What about that second line of code? If you're familiar with JavaScript, you'll know this fails with an exception:
TypeError: foo is not a function
It'd be great if we could avoid mistakes like this.
When we run our code, the way that our JavaScript runtime chooses what to do is by figuring out the type of the value - what sorts of behaviors and capabilities it has.
That's part of what that TypeError
is alluding to - it's saying that there's nothing to call on the string "Hello World"
.
For some values, such as the primitives string
and number
, we can identify their type at runtime using the typeof
operator.
But for other things like functions, there's no corresponding runtime mechanism to identify their types.
For example, consider this function:
function fn(x) { return x.flip(); }Try
We can observe by reading the code that this function will only work if given an object with a callable flip
property, but JavaScript doesn't surface this information in a way that we can check while the code is running.
The only way in pure JavaScript to tell what fn
does with a particular value is to call it and see what happens.
This kind of behavior makes it hard to predict what code will do before it runs, which means it's harder to know what your code is going to do while you're writing it.
Seen in this way, a type is the concept of describing which values are legal to pass to fn
and which aren't legal.
JavaScript only truly provides dynamic typing - running the code to see what happens.
The alternative is to use a static type system to make predictions about what code is legal before it runs.
Static type-checking
Think back to that TypeError
we got earlier from calling a string
.
Most people don't like to get any sorts of errors when running their code - those are considered bugs!
And when we write new code, we try our best to avoid introducing new bugs.
If we add just a bit of code, save our file, refresh our app, and immediately see the error, we might be able to isolate the problem quickly; but that's not always the case. We might not have tested the feature thoroughly enough, so we might never actually run into a potential error that would be thrown! Or if we were lucky enough to witness the error, we might have ended up doing large refactorings and adding a lot of different code that we're forced to dig through.
Ideally, we could have a tool that helps us find these bugs before our code runs. That's what a static type-checker like TypeScript does. Static types systems describe the shapes and behaviors of what our values will be when we run our programs. A type-checker like TypeScript uses that information and tells us when things might be going off the rails.
let foo = "hello!"; foo()Cannot invoke an expression whose type lacks a call signature. Type 'String' has no compatible call signatures.;TryCannot invoke an expression whose type lacks a call signature. Type 'String' has no compatible call signatures.
Running that last sample with TypeScript will give us an error message before we run the code in the first place.
Non-exception Failures
So far we've been discussing certain things like runtime errors - cases where the JavaScript runtime throws its hands up and tells us that it thinks something is nonsensical. Those cases come up because the ECMAScript specification has explicit instructions on how the language should behave when it runs into something unexpected.
For example, the specification says that trying to call something that isn't callable should throw an error.
Maybe that sounds like "obvious behavior", but you could imagine that accessing a property that doesn't exist on an object should throw an error too.
Instead, JavaScript gives us different behavior and returns the value undefined
:
let foo = { name: "Daniel", age: 26, }; foo.location; // returns undefinedTry
Ultimately, a static type system has to make the call over what code should be flagged as an error in its system, even if it's "valid" JavaScript that won't immediately throw an error.
In TypeScript, the following code produces an error about location
not being defined:
let foo = { name: "Daniel", age: 26, }; foo.locationProperty 'location' does not exist on type '{ name: string; age: number; }'.; // returns undefinedTryProperty 'location' does not exist on type '{ name: string; age: number; }'.
While sometimes that implies a trade-off in what you can express, the intent is to catch legitimate bugs in our programs. And TypeScript catches a lot of legitimate bugs. For example: typos,
let someString = "Hello World!"; // How quickly can you spot the typos? someString.toLocaleLowercaseProperty 'toLocaleLowercase' does not exist on type 'string'. Did you mean 'toLocaleLowerCase'?(); someString.toLocalLowerCaseProperty 'toLocalLowerCase' does not exist on type 'string'. Did you mean 'toLocaleLowerCase'?(); // We probably meant to write this... someString.toLocaleLowerCase();Property 'toLocaleLowercase' does not exist on type 'string'. Did you mean 'toLocaleLowerCase'?TryProperty 'toLocalLowerCase' does not exist on type 'string'. Did you mean 'toLocaleLowerCase'?
uncalled functions,
function flipCoin() { return Math.random < 0.5Operator '<' cannot be applied to types '() => number' and 'number'.; }TryOperator '<' cannot be applied to types '() => number' and 'number'.
or basic logic errors.
const value = Math.random() < 0.5 ? "a" : "b"; if (value !== "a") { // ... } else if (value === "b"This condition will always return 'false' since the types '"a"' and '"b"' have no overlap.) { // Oops, unreachable }TryThis condition will always return 'false' since the types '"a"' and '"b"' have no overlap.
Types for Tooling
TypeScript can catch bugs when we make mistakes in our code. That's great, but TypeScript can also prevent us from making those mistakes in the first place.
The type-checker has information to check things like whether we're accessing the right properties on variables and other properties. Once it has that information, it can also start suggesting which properties you might want to use.
That means TypeScript can be leveraged for editing code too, and the core type-checker can provide error messages and code completion as you type in the editor. That's part of what people often refer to when they talk about tooling in TypeScript.
TypeScript takes tooling seriously, and that goes beyond completions and errors as you type. An editor that supports TypeScript can deliver "quick fixes" to automatically fix errors, refactorings to easily re-organize code, and useful navigation features for jumping to definitions of a variable, or finding all references to a given variable. All of this is built on top of the type-checker and fully cross-platform, so it's likely that your favorite editor has TypeScript support available.
tsc
, the TypeScript compiler
We've been talking about type-checking, but we haven't yet used our type-checker.
Let's get acquainted with our new friend tsc
, the TypeScript compiler.
First we'll need to grab it via npm.
npm install -g typescript
This installs the TypeScript Compiler
tsc
globally. You can usenpx
or similar tools if you'd prefer to runtsc
from a localnode_modules
package instead.
Now let's move to an empty folder and try writing our first TypeScript program: hello.ts
:
// Greets the world. console.log("Hello world!");
Notice there are no frills here; this "hello world" program looks identical to what you'd write for a "hello world" program in JavaScript.
And now let's type-check it by running the command tsc
which was installed for us by the typescript
package.
tsc hello.ts
Tada!
Wait, "tada" what exactly?
We ran tsc
and nothing happened!
Well, there were no type errors, so we didn't get any output in our console since there was nothing to report.
But check again - we got some file output instead.
If we look in our current directory, we'll see a hello.js
file next to hello.ts
.
That's the output from our hello.ts
file after tsc
compiles or transforms it into a JavaScript file.
And if we check the contents, we'll see what TypeScript spits out after it processes a .ts
file:
// Greets the world. console.log("Hello world!");
In this case, there was very little for TypeScript to transform, so it looks identical to what we wrote. The compiler tries to emit clean readable code that looks like something a person would write. While that's not always so easy, TypeScript indents consistently, is mindful of when our code spans across different lines of code, and tries to keep comments around.
What about if we did introduce a type-checking error?
Let's rewrite hello.ts
:
// This is an industrial-grade general-purpose greeter function: function greet(person, date) { console.log(`Hello ${person}, today is ${date}!`); } greet("Brendan")Expected 2 arguments, but got 1.;TryExpected 2 arguments, but got 1.
If we run tsc hello.ts
again, notice that we get an error on the command line!
Expected 2 arguments, but got 1.
TypeScript is telling us we forgot to pass an argument to the greet
function, and rightfully so.
So far we've only written standard JavaScript, and yet type-checking was still able to find problems with our code.
Thanks TypeScript!
Emitting with Errors
One thing you might not have noticed from the last example was that our hello.js
file changed again.
If we open that file up then we'll see that the contents still basically look the same as our input file.
That might be a bit surprising given the fact that tsc
reported an error about our code, but this based on one of TypeScript's core values: much of the time, you will know better than TypeScript.
To reiterate from earlier, type-checking code limits the sorts of programs you can run, and so there's a tradeoff on what sorts of things a type-checker finds acceptable. Most of the time that's okay, but there are scenarios where those checks get in the way. For example, imagine yourself migrating JavaScript code over to TypeScript and introducing type-checking errors. Eventually you'll get around to cleaning things up for the type-checker, but that original JavaScript code was already working! Why should converting it over to TypeScript stop you from running it?
So TypeScript doesn't get in your way.
Of course, over time, you may want to be a bit more defensive against mistakes, and make TypeScript act a bit more strictly.
In that case, you can use the --noEmitOnError
compiler option.
Try changing your hello.ts
file and running tsc
with that flag:
tsc --noEmitOnError hello.ts
You'll notice that hello.js
never gets updated.
Explicit Types
Up until now, we haven't told TypeScript what person
or date
are.
Let's change up our code a little bit so that we tell TypeScript that person
is a string
, and that date
should be a Date
object.
We'll also use the toDateString()
method on date
.
function greet(person: string, date: Date) { console.log(`Hello ${person}, today is ${date.toDateString()}!`); }Try
What we did was add type annotations on person
and date
to describe what types of values greet
can be called with.
You can read that signature as "greet
takes a person
of type string
, and a date
of type Date
".
With this, TypeScript can tell us about other cases where we might have been called incorrectly. For example…
function greet(person: string, date: Date) { console.log(`Hello ${person}, today is ${date.toDateString()}!`); } greet("Maddison", Date()Argument of type 'string' is not assignable to parameter of type 'Date'.);TryArgument of type 'string' is not assignable to parameter of type 'Date'.
Huh? TypeScript reported an error on our second argument, but why?
Perhaps surprisingly, calling Date()
in JavaScript returns a string
.
On the other hand, constructing a Date
with new Date()
actually gives us what we were expecting.
Anyway, we can quickly fix up the error:
function greet(person: string, date: Date) { console.log(`Hello ${person}, today is ${date.toDateString()}!`); } greet("Maddison", new Date());Try
Keep in mind, we don't always have to write explicit type annotations. In many cases, TypeScript can even just infer (or "figure out") the types for us even if we omit them.
let foo = "hello there!" ▲let foo: string
Even though we didn't tell TypeScript that foo
had the type string
it was able to figure that out.
That's a feature, and it's best not to add annotations when the type system would end up inferring the same type anyway.
Erased Types
Let's take a look at what happens when we compile with tsc
:
function greet(person, date) { console.log("Hello " + person + ", today is " + date.toDateString() + "!"); } greet("Maddison", new Date());Try
Notice two things here:
- Our
person
anddate
parameters no longer have type annotations. - Our "template string" - that string that used backticks (the
`
character - was converted to plain strings with concatenations (+
).
More on that second point later, but let's now focus on that first point. Type annotations aren't part of JavaScript (or ECMAScript to be pedantic), so there really aren't any browsers or other runtimes that can just run TypeScript unmodified. That's why TypeScript needs a compiler in the first place - it needs some way to strip out or transform any TypeScript-specific code so that you can run it. Most TypeScript-specific code gets erased away, and likewise, here our type annotations were completely erased.
Remember: Type annotations never change the runtime behavior of your program.
Downleveling
One other difference from the above was that our template string was rewritten from
`Hello ${person}, today is ${date.toDateString()}!`
to
"Hello " + person + ", today is " + date.toDateString() + "!"
Why did this happen?
Template strings are a feature from a version of ECMAScript called ECMAScript 2015 (a.k.a. ECMAScript 6, ES2015, ES6, etc. - don't ask). TypeScript has the ability to rewrite code from newer versions of ECMAScript to older ones such as ECMAScript 3 or ECMAScript 5 (a.k.a. ES3 and ES5). This process from moving from a newer or "higher" version of ECMAScript to an older or "lower" one is sometimes called downleveling.
By default TypeScript targets ES3, an extremely old version of ECMAScript.
We could have chosen something a little bit more recent by using the --target
flag.
Running with --target es2015
changes TypeScript to target ECMAScript 2015, meaning code should be able to run wherever ECMAScript 2015 is supported.
So running tsc --target es2015 input.ts
gives us the following output:
function greet(person, date) { console.log(`Hello ${person}, today is ${date.toDateString()}!`); } greet("Maddison", new Date());Try
While the default target is ES3, the great majority of running browsers support ES5. Today, most developers can safely specify ES5 or even ES2016 as a target unless compatibility with certain ancient browers is important.
Strictness
Users come to TypeScript looking for different things in a type-checker.
Some people are looking for a more loose opt-in experience which can help validate only some parts of our program and give us decent tooling.
This is the default experience with TypeScript, where types are optional, inference takes the most lenient types, and there's no checking for potentially null
/undefined
values.
Much like how tsc
emits in the face of errors, these defaults are put in place to stay out of your way.
If you're migrating existing JavaScript, that might be desirable.
In contrast, a lot of users prefer to have TypeScript validate as much as it can off the bat, and that's why the language provides strictness settings as well. These strictness settings turn static type-checking from a switch (either your code is checked or not) into something closer to a dial. The farther you turn this dial up, the more TypeScript will check for you. This can require a little extra work, but generally speaking it pays for itself in the long run, and enables more thorough checks and more accurate tooling. If possible, a new codebase should always turn these strictness checks on.
TypeScript has several type-checking strictness flags that can be turned on or off, and all of our examples will be written with all of them enabled unless otherwise stated.
The --strict
flag toggles them all on simultaneously, but we can opt out of them individually.
The two biggest ones you should know about are noImplicitAny
and strictNullChecks
.
noImplicitAny
Recall that in some places, TypeScript doesn't try to infer any types for us and instead falls back to the most lenient type: any
.
This isn't the worst thing that can happen - after all, falling back to any
is just the JavaScript experience anyway.
However, using any
often defeats the purpose of using TypeScript in the first place.
The more typed your program is, the more validation and tooling you'll get, meaning you'll run into fewer bugs as you code.
Turning on the noImplicitAny
flag will issue an error on any variables whose type is implicitly inferred as any
.
strictNullChecks
By default, values like null
and undefined
are assignable to any other type.
This can make writing some code easier, but forgetting to handle null
and undefined
is the cause of countless bugs in the world - not even just JavaScript!
The strictNullChecks
flag makes handling null
and undefined
more explicit, and spares us from worrying about whether we forgot to handle null
and undefined
.