How not to learn TypeScript
“TypeScript and I are never going to be friends”. Oh wow, how often have I heard this phrase? Learning TypeScript, even in 2022, can be frustrating it seems. And for so many different reasons. People who write Java or C# and find out things are working differently than they should. Folks who have done JavaScript most of their time and are being screamed at by a compiler. Here are some mistakes I’ve seen people do when getting started with TypeScript. I hope they’re helpful to you!
This article has been very much influenced by Denys’ How not to learn Rust which I can highly recommend.
Mistake 1: Ignore JavaScript #
TypeScript is a superset of JavaScript and has been advertised like this ever since. What this means is that JavaScript is very much part of the language. All of it. Choosing TypeScript does not give you a free card to ditch JavaScript and its erratic behavior. But TypeScript makes it easier to understand it. And you can see JavaScript breaking through everywhere.
See my blog post on error handling for example. It would be very reasonable to allow catching for an error like you’re used to from other programming languages:
try {
// something with Axios, for example
} catch(e: AxiosError) {
// ^^^^^^^^^^ Error 1196 💥
}
But this is not possible. And the reason is because of how JavaScript errors work (check the respective article for more details). Code that would make sense in TypeScript, but isn’t doable in JavaScript.
Another example, using Object.keys
and expecting simple property access is also something you would expect, but will cause problems.
type Person = {
name: string, age: number, id: number,
}
declare const me: Person;
Object.keys(me).forEach(key => {
// 💥 the next line throws red squigglies at us
console.log(me[key])
})
There is a way to patch this behavior as detailled here, but this patch can’t be applied to each and every scenario. TypeScript just can’t guarantee based on your code that the types for this property access will be the ones you’d expect. Code that runs perfectly fine in JavaScript alone, but which is hard to express with the type system for so many reasons.
If you’re learning TypeScript with no JavaScript background whatsoever, start learning to differentiate between JavaScript and the type system. Also, learn to search for the right things. Named parameters in functions. You can do that with objects as arguments. A nice pattern. It’s part of JavaScript, though. Conditional chaining? Implemented in the TypeScript compiler first, but it’s also a JavaScript feature. Classes and extending existing classes? JavaScript. Private class fields? You know, the ones with the #
in front of them, a little fence so nobody can access what’s behind it. Also JavaScript.
Program code that actually does something is most of the time in the JavaScript camp. If you are using types to express intent and contracts, you’re in type land.
Recently the TypeScript website has a much clearer statement on what it means to use TypeScript: TypeScript is JavaScript with syntax for types. It’s right here. TypeScript is JavaScript. Understanding JavaScript is key to understanding TypeScript.
Mistake 2: Annotate everything #
A type annotation is a way to explicitly tell which types to expect. You know, the stuff that was very prominent in other programming languages, where the verbosity of StringBuilder stringBuilder = new StringBuilder()
makes sure that you’re really, really dealing with a StringBuilder
. The opposite is type inference, where TypeScript tries to figure out the type for you. let a_number = 2
is of type number
.
Type annotations are also the most obvious and visible syntax difference between TypeScript and JavaScript.
When you start learning TypeScript, you might want to annotate everything to express the types you’d expect. This might feel like the obvious choice when starting with TypeScript, but I implore you to use annotations sparingly and let TypeScript figure out types for you. Why? Let me explain what a type annotation actually is.
A type annotation is a way for you to express where contracts have to be checked. If you add a type annotation to a variable declaration, you tell the compiler to check if types match during the assignment.
type Person = {
name: string,
age: number
}
const me: Person = createPerson()
If createPerson
returns something that isn’t compatible with Person
, TypeScript will error. Do this is if you really want to be sure that you’re dealing with the right type here.
Also, from that moment on, me
is of type Person
, and TypeScript will treat it as a Person
. If there are more properties in me
, e.g. a profession
, TypeScript won’t allow you to access them. It’s not defined in Person
.
If you add a type annotation to a function signature’s return value, you tell the compiler to check if types match the moment you return that value.
function createPerson(): Person {
return { name: "Stefan", age: 39 }
}
If I return something that doesn’t match Person
, TypeScript will error. Do this if you want to be completely sure that you return the correct type. This especially comes in handy if you are working with functions that construct big objects from various sources.
If you add a type annotation to a function signature’s parameters, you tell the compiler to check if types match the moment you pass along arguments.
function printPerson(person: Person) {
console.log(person.name, person.age)
}
printPerson(me)
This is in my opinion the most important, and unavoidable type annotation. Everything else can be inferred.
type Person = {
name: string,
age: number
}
// Inferred!
// return type is { name: string, age: number }
function createPerson() {
return { name: "Stefan", age: 39}
}
// Inferred!
// me is type of { name: string, age: number}
const me = createPerson()
// Annotated! You have to check if types are compatible
function printPerson(person: Person) {
console.log(person.name, person.age)
}
// All works
printPerson(me)
Always use type annotations with function parameters. This is where you have to check your contracts. This is not only a lot more convenient, it also comes with a ton of benefits. You get e.g. polymorphism for free.
type Person = {
name: string,
age: number
}
type Studying = {
semester: number
}
type Student = {
id: string,
age: number,
semester: number
}
function createPerson() {
return { name: "Stefan", age: 39, semester: 25, id: "XPA"}
}
function printPerson(person: Person) {
console.log(person.name, person.age)
}
function studyForAnotherSemester(student: Studying) {
student.semester++
}
function isLongTimeStudent(student: Student) {
return student.age - student.semester / 2 > 30 && student.semester > 20
}
const me = createPerson()
// All work!
printPerson(me)
studyForAnotherSemester(me)
isLongTimeStudent(me)
Student
, Person
and Studying
have some overlap, but are unrelated to each other. createPerson
returns something that is compatible with all three types. If we’d have annotated too much, we would need to create a lot more types and a lot more checks than necessary, without any benefit.
When learning TypeScript, not relying too much on type annotations also gives you a really good feel of what it means to work with a structural type system.
Mistake 3: Mistake types for values #
TypeScript is a super-set of JavaScript, which means it adds more things to an already existing and defined language. Over time you learn to spot which parts are JavaScript, and which parts are TypeScript.
It really helps to see TypeScript as this additional layer of types upon regular JavaScript. A thin layer of meta-information, which will be peeled off before your JavaScript code runs in one of the available runtimes. Some people even speak about TypeScript code “erasing to JavaScript” once compiled.
TypeScript being this layer on top of JavaScript also means that different syntax contributes to different layers. While a function
or const
creates a name in the JavaScript part, a type
declaration or an interface
contributes a name in the TypeScript layer. E.g.
// Collection is in TypeScript land! --> type
type Collection<T> = {
entries: T[]
}
// printCollection is in JavaScript land! --> value
function printCollection(coll: Collection<unknown>) {
console.log(...coll.entries)
}
We also say that names or declarations contribute either a type or a value. Since the type layer is on top of the value layer, it’s possible to consume values in the type layer, but not vice versa. We also have explicit keywords for that.
// a value
const person = {
name: "Stefan"
}
// a type
type Person = typeof person;
typeof
creates a name available in the type layer from the value layer below.
It gets irritating when there are declaration types that create both types and values. Classes for instance can be used in the TypeScript layer as a type, as well as in JavaScript as a value.
// declaration
class Person {
name: string
constructor(n: string) {
this.name = n
}
}
// value
const person = new Person("Stefan")
// type
type PersonCollection = Collection<Person>
function printPersons(coll: PersonCollection) {
//...
}
And naming conventions trick you. Usually, we define classes, types, interfaces, enums, etc. with a capital first letter. And even if they may contribute values, they for sure contribute types. Well, until you write uppercase functions for your React app, at least.
If you’re used to using names as types and values, you’re going to scratch your head if you suddenly get a good old TS2749: ‘YourType’ refers to a value, but is being used as a type error.
type PersonProps = {
name: string
}
function Person({ name }: PersonProps) {
return <p>{name}</p>
}
type Collection<T> = {
entries: T
}
type PrintComponentProps = {
collection: Collection<Person> // ERROR!
// 'Person' refers to a value, but is being used as a type
}
This is where TypeScript can get really confusing. What is a type, what is a value, why do we need to separate this, why doesn’t this work like in other programming languages? Suddenly, you see yourself confronted with typeof
calls or even the InstanceType
helper type, because you realize that classes actually contribute two types (shocking!).
So it’s good to understand what contributes types, and what contributes value. What are the boundaries, how and in which direction can we move, and what does this mean for your typings? This table, adapted from the TypeScript docs, sums it up nicely:
Declaration type | Type | Value |
---|---|---|
Class | X | X |
Enum | X | X |
Interface | X | |
Type Alias | X | |
Function | X | |
Variable | X |
When learning TypeScript, it’s probably a good idea to focus on functions, variables, and simple type aliases (or interfaces, if that’s more your thing). This should give you a good idea about what happens in the type layer, and what happens in the value layer.
Mistake 4: Going all-in in the beginning #
We’ve spoken a lot about what mistakes somebody can make coming to TypeScript from a different programming language. To be fair, this has been my bread and butter for quite a while. But there’s also a different trajectory: People who have written plenty of JavaScript, suddenly being confronted with another, sometimes very annoying tool.
This can lead to very frustrating experiences. You know your codebase like the back of your hand, suddenly a compiler is telling you that it doesn’t understand things left and right and that you’ve made mistakes even though you know that your software will work.
And you wonder how everyone can even like this bugger. TypeScript is supposed to help you be productive, but then all it does is throw distracting, red squigglies under your code.
We’ve all been there, haven’t we?
And I can relate to that! TypeScript can be very loud, especially if you “just switch it on” in an existing JavaScript codebase. TypeScript wants to get a sense of your entire application, and this requires you to annotate everything so contracts align. How cumbersome.
If you come from JavaScript, I’d say you should make use of TypeScript’s gradual adoption features. TypeScript has been designed to make it as easy for you to just adopt a little bit, before going all-in:
- Take parts of your application and move them to TypeScript, rather than moving everything. TypeScript has JavaScript interoperability (
allowJS
) - TypeScript emits compiled JavaScript code even when TypeScript finds errors in your code. You have to turn off code emitting explicitly using the
noEmitOnError
flag. Not turning it on allows you to still ship even though your compiler screams at you. - Use TypeScript by writing type declaration files and importing them via JSDoc. This is a good first step in getting more information on what’s happening inside your codebase.
- Use any everywhere it would be too overwhelming or too much effort. Contrary to popular beliefs, using any is absolutely ok, as long as it is used explicitly
Check out the tsconfig
reference to see which configuration flags are available. TypeScript has been designed for gradual adoption. You can use as many types as you like. You can leave big parts of your application in JavaScript, and this should definitely help you get started.
When learning TypeScript as a JavaScript developer, don’t ask too much from yourself. Try to use it as inline documentation to reason better about your code, and extend/improve upon that.
Mistake 5: Learn the wrong TypeScript #
Again, very inspired by How not to learn Rust. If your code needs to use one of the following keywords, you’re probably either in the wrong corner of TypeScript, or much further than you want to be:
namespace
declare
module
<reference>
abstract
unique
This doesn’t mean that those keywords don’t contribute something very important and are necessary for a variety of use-cases. When learning TypeScript, you don’t want to work with them at the beginning, though.
And that’s it! I’m curious on how you learned TypeScript and what obstacles you hit when starting out. Also, do you know of other things that might be common mistakes when learning TypeScript? Let me know! I’m eager to hear your stories.