Introduction to TypeScript
by Nicklas EnvallTypeScript is a programming language that transpile to JavaScript. It's vital to understand that we compile our TS code to JS code so that the browsers can run it. TypeScript itself is a superset of JavaScript and gives us a type system with optional type checking (note optional). TypeScript also allows us to use the latest features of ECMAScript and will transpile to ES3 or ES5 depending on what we target.
TypeScript matches types by its structure, which is known as a structural type system. A nominal type system would match against the type’s name. TypeScript also has something called type inference, which means that the compiler can analyze the context to infer (conclude) types automatically. Type annotation is what we do to help out the compiler.
Let's look at a couple of selling points for TypeScript. However, note that many developers disagree with this, I'm not saying that this is absolute, this is just a list to help you get the gist of it:
- Typescript gives a type system for JavaScript.
- TypeScript allows us to use the latest features of JavaScript.
- Types increase code quality and understandability.
- TypeScript's compiler will catch errors for us.
- TypeScript throw errors for weird things like
[] + []
. - TypeScript prevents us from using an undeclared variable.
- You can edit the compiler to suit your project.
Getting Familiar with TypeScript 📚
We'll start with the essentials things like, how to annotate types. Followed by exploring TypeScript's special types, and how to use arrays, tuples, object literals, functions.
1. How do we declare types to our variables?
Types are annotated using : type
syntax. A typical variable assignment looks like, DECLARE_SYMBOL VARIABLE_NAME: TYPE = VALUE
. We have three primitive types:
- number
- string
- boolean
We would declare our variables in the following manner:
let num: number; let str: string; let bool: boolean;
We can also declare our variables' types explicitly or implicitly:
// explicit let foo: number; foo = 300; foo = 'hello'; // Error: cannot assign string to number // implicit let foo = 300; foo = 'hello'; // Error: cannot assign string to number
We can also create union types by using |
to declare multiple types for a variable:
let x: number | string; x = 30; x = 'hello';
2. Special types in TypeScript
Other than number
, string
, and boolean
TypeScript also have special types which we'll now look at. The any
type is like an escape hatch, a variable with that type can hold any type of value:
let val: any; val = 'hello'; // OK val = 30; // OK val = {}; // OK
We also have void
, which is a return type that indicates that the function returns nothing. Soon at point 7, we'll look more at return types.
const func = (): void => console.log('hello');
JavaScript functions return undefined
by default. A function with the never
return type never returns anything, note that it does not return undefined like void
. The following example illustrates the difference between void
and never
:
const func = (): never => { throw Error('im an error'); }; console.log(func()); // this is just blank not even undefined const voidFunc = (): void => { }; console.log(voidFunc()); // returns undefined
Then we have the unknown
type which is similar to any
because just like any
we can store any value without errors, but look at the following example:
let input: unknown; let msg: string; input = 5; input = 'hello'; msg = input; // ERROR: since the type is unknown.
Lastly, we have**undefined
** and null
. The undefined
type entails that something has not been defined yet, while null
represents the absence of value.
3. Arrays in TypeScript
We can define our arrays to hold a specific type of value:
let numArr: number[]; let strArr: string[]; let boolArr: boolean[]; numArr = []; numArr.push(1); // OK numArr.push('hello'); // ERROR numArr.push(true); // ERROR
4. Object Literal in TypeScript
You can do inline annotation or let the compiler infer the type. The following example uses inline annotation:
let A: { name: string, age: number } = { name: 'fred', age: 26 }; A.name = 20; // ERROR: wrong type
The example below lets the compiler figure out the type:
let B = { name: 'jones' }; B.age = 26; // ERROR: does not exist B.name = 50 // ERROR: wrong type
5. Tuples in TypeScript
With TypeScript, you can make your own tuples:
let point: [number, number]; point = [0, 1]; // OK point = ['hello', 1]; // ERROR
6. Type Alias
A Type Alias is a name that points to a defined type. We mainly use type aliases to give our types a semantic meaning, which makes our code more readable. Let's start by looking at a basic example:
type Point = [number, number]; const MapPoint: Point = [1, 2];
We can also define call signatures as types. Thanks to creating a type, we can do cb: Callback
instead of cb: (data: any) => void
, in the example below:
type Callback = (data: any) => void; const doAsyncRequest = (url: string, cb: Callback) => { // do something with url cb({ statusCode: 200 }); } doAsyncRequest('...', (data) => console.log(data));
Furthermore, intersection types often come up when working with type aliases:
type BeverageFacts = { containsBubbles: boolean, name: string } type NutritionFacts = { calories: number } type Beverage = BeverageFacts & NutritionFacts; let soda: Beverage = { containsBubbles: true, name: 'Fizzy Blueberry', calories: 100 };
7. Functions in TypeScript
A TypeScript function is essentially a JavaScript function with added type checks. With functions at its core, we can:
- Annotate the parameters' types.
- Annotate the return type.
The types we choose for our functions can be added with inline annotation or types that we've already defined. In the example below, we can see that annotating parameters is just like any other variable annotation:
// inline annotation const func = (param: type): returnType => { … };
1. Call Signature: we have two different types of call signatures:
- Shorthand Call Signature:
(a: number, b: number) => number
- Full Call Signature:
(a: number, b: number): number
2. Return Types: with return types, we explicitly inform the compiler of what is going to get returned.
const add = (x: number, y: number) => 'hello'; // OK const add = (x: number, y: number): number => 'hello'; // ERROR
3. Contextual typing: contextual typing means that TypeScript's compiler can figure out parameter types with the context. In the example below, the compiler contextually understands that n
will have a number type.
function doSomething(cb: (num: number) => void) { cb(1); } doSomething((n) => console.log(n));
4. Overloaded function: an overloaded function has multiple call signatures. Be aware that, only because you can overload doesn't mean you should, sometimes union typing is to be preferred.
type Merge = { (x: number, y: number): number (x: string, y: string): string } const merger: Merge = (x: any, y: any) => x + y; merger(1, 2); // OK merger('hello', 'hello'); // OK merger(true, 'hello'); // ERROR
5. Functions expect a certain amount of arguments to be passed: in JavaScript, we can define and call a function like:
const func = (param) => param; func(); // OK func(1, 2); // OK
The point being, we don't have to give the "expected" parameters. However, this is not the case in TS:
const func = (param1: any, param2: any) => param1 + param2; func(1); // ERROR func(1, 2); // OK func(1, 2, 3); // ERROR // But we can mark params as optional with a ? sign: const func = (requiredParam: type, optionalParam?: type) => { … };
A Closer Look at TypeScript 🔎
Now that we've covered the essentials, we can take a closer look at what TypeScript has to offer. We'll start by looking at what type assertion is, followed by generics, interfaces, enums, and some good to know things.
1. Type Assertion
With type assertion, you force the compiler to accept a type. You're essentially saying that you know more about the object than the compiler does and that it should stop bothering you. This also means that it's your responsibility to make sure you're asserting correctly, else you'll get a runtime error because type assertion won’t change anything that occurs during runtime.
In the following example, we get an object with the HTMLElement
type (all elements are derived from this type). But, the compiler will throw an error in this case, because the value
property does not exist on the HTMLElement
type:
// HTML: <input id='1' value='somevalue'/> const inputElement = document.getElementById('1'); inputElement.value; // ERROR
The problem is that the getElementById
method's return type is HTMLElement
. To fix this, we can assert the type, to be HTMLInputElement
, we do this with either angel brackets or the as keyword. Note that you should not use angel brackets when working with JSX-syntax.
// HTML: <input id='1' value='somevalue'/> const inputElement = <HTMLInputElement>document.getElementById('1') inputElement.value; // OK const inputElement = document.getElementById('1') as HTMLInputElement; inputElement.value; // OK
2. Generics
Generics are a great way to make our code more reusable and DRY. In the example below, we have a function that returns the param. In this case, it’ll only work for numbers, even though we should be able to reuse it for strings as well.
const func = (param: number) => param; const num = func(1); // OK const str = func(''); // ERROR
To make our code more DRY, we can use the any
type:
const func = (param: any) => param; const num = func(1); // OK const str = func(''); // OK
As we see, the any
type makes it work, but the problem now is that the inferred return type is any
. This is a problem because our variables will get the any
type, which means we lose type safety. Luckily, this is where generics can help us out. In the example below, the parameter has the type T
which indicates that its type could be anything:
const func = <T>(param: T) => param; const num = func(1); // OK const str = func(''); // OK // Note: you can make it explicit by, func<string>('');
Now, even though the type is generic, we can still put constraints by using extend
. Let’s look at an example with an object:
const addFullName = <T extends { firstName: string, lastName: string }> (obj: T) => ({ ...obj, fullName: `${obj.firstName} ${obj.lastName}` }); const obj = addFullName({ firstName: 'james', lastName: 'bond' });
You could also do T extends {}
if you want it to be a generic object. You can of course also do something like T extends string
because it's not exclusive to objects. We can also use multiple generic parameters. The function below lets you merge two objects while giving the result a combined type:
function extendObj<T, U>(obj1: T, obj2: U): T & U { return {...obj1, ...obj2}; }
3. Interfaces
What is an interface in TypeScript? Well, an interface is like a contract, it defines properties and methods for an object that must adhere to the defined structure. We use interfaces because it gives us an easy way to define the structure of our objects or function signatures. For example, if a class that implements the interface does not adhere to the interface, then an error will be thrown.
1. Interfaces for Object literals and ES6 classes:
interface Point { x: number; y: number; } const p1: Point = { x: 1, y: 2 }; // OK const p2: Point = { x: 1, b: 2 }; // ERROR: 'b' does not exist in type 'Point' class MapPoint implements Point { x: number = 1; y: number = 2; }
2. Extending interface with another interface: interfaces are open-ended, which means that interfaces can extend one or more interfaces.
interface A { a: number; } interface B extends A { b: number; } const x: B = { a: 1, b: 2 }; // OK const y: B = { a: 1, b: 2, c: 3 }; // ERROR
3. Interface for a function:
interface Sum { (x: number, y: number): number; } const func1: Sum = (x, y) => x + y; // OK const func2: Sum = (x:string, y) => x + y; // ERROR: wrong param type const func3: Sum = (x, y) => true; // ERROR: wrong return type func1(1, 2); // OK func1('string', 2); // ERROR
4. readonly
You can mark properties (works with interfaces, types, classes) as read-only if you want. Here are some things to think about:
- You may initialize readonly properties in the constructor of classes.
- There's a predefined type
Readonly
that you can use to make all properties read-only. - Don't confuse
readonly
withconst
,const
is for the variable, not the reference of the value.
interface A { readonly x: string, readonly y: number } const a: A = { x: 'x', y: 123 }; a.x = 'eeee'; // ERROR: we are not allowed to mutate a readonly
5. Index Properties
Index Properties gives us extra flexibility because we do not have to know the names of the keys, nor do we have to know how many.
interface Something { [prop: string]: string; } const x: Something = { 'hello': 1 }; // ERROR const y: Something = { 'hello': 'greeting' }; // OK
6. Enums
TypeScript gives us Enums, which consists of enumerated types that can make our code more readable:
enum Flag { Yellow, // 0 Red, // 1 Blue // 2 } Flag.Yellow; // 0 Flag[0]; // 'Yellow'
Enums start at 0 and increments upwards, but you can do the following:
enum Flag { Yellow = 4, // 4 Red, // 5 Blue // 6 }
Furthermore, just like interfaces, you can also extend enums. Keep in mind to correctly declare it to start at the correct number. In this case, we use 2:
enum Animal { Cow, Dog } enum Animal { Cat = 2, Tiger }
7. Namespaces in TypeScript
Namespaces are a way to encapsulate code, but keep in mind that most often, the preferred way is to use modules. With namespaces, we export functions, variables, other namespaces, types, interfaces, etc. Everything inside the namespace that is not exported becomes private to that block.
namespace A { export function print(msg: string) { console.log(msg); } export namespace B { export function shout(msg: string) { console.log(msg + '!'); } } } A.print('hello'); // hello A.B.shout('hello'); // hello!
Getting Advanced with TypeScript ✨
Now that we’ve covered the essentials and took a closer look at TypeScript, it’s time to get a bit more advanced. We’ll start by looking at type declarations, which will give us a better idea of how TypeScript works, followed by freshness, type guards, and literal types.
1. Type Declarations
What would happen if you used an external library that does not have types? Well, things would go wrong because the TypeScript compiler won't understand the library. Luckily, we can create type declaration files that declare the types for the library. The declaration files have the extension .d.ts
to separate them from the implementation files. Note that the declaration files do not contain the implementation details, just the type declarations.
But even though if the library you want to use is written in TypeScript, most likely the library will give you a compiled version of the code. This is because it would be a waste to give the code both in TypeScript and JavaScript. The TypeScript code would also have to be compiled by the developers that use the library. So instead, these libraries provide a type declaration file that the compiler uses.
But what if the library you want to use does not give you a type declaration file? Then you could create your own, but declaring libraries would quickly become a hassle, which is why we in those cases use DefinitelyTyped, which is a library (see https://www.npmjs.com/~types), which contains type declarations for libraries created by the community. You can use the search function at https://microsoft.github.io/TypeSearch/ to quickly see if the types for the library you want to use exists there.
Interestingly enough, TypeScript has its own lib.d.ts
file that it references each time it compiles. The lib.d.ts
file contains a bunch of declarations like, window
, document
and math
. These type declarations (and all other type declarations) help us make sure that we use the libraries correctly, things like using the correct amount of params for functions or valid property names for some object.
Declare keyword
We use the declare
keyword when creating type declaration files. The keyword is used to do ambient declarations. We call them ambient declarations to separate them from declarations like let
and const
. With ambient declarations, we are telling the compiler to trust that what we declare exists. We won’t cover this in detail because it’s beyond the scope of this article. But, there are three types of ambient declarations:
- Ambient Variable Declaration
- Ambient Type Declaration
- Ambient Module Declaration
2. Freshness (Strict Object Literal Checking)
TypeScript has a strict object literal checking, which helps with misspelled or excessive properties. TypeScript separates “fresh” literals with objects that are have been assigned to a variable. Study the following example:
const func = (obj: { prop: string }) => obj.prop; const A = { prop: 'hello' }; const B = { prop: 'hello2', name: 'james' }; const C = { name: 'james' }; func(A); // OK func(B); // OK func(C); // ERROR // fresh literals will fail if properties are excessive func({ prop: 'hello' }) // OK func({ prop: 'hello2', name: 'james' }) // ERROR
This makes sense, because why would you have excessive properties for a fresh literal? However, you can use ?
to mark properties as optional, this would mean you don't need to give all the parameters, but it would still disallow you giving properties that have not been specified (ie typos). Excess properties can also be permitted if you do the following:
let A: { b: string, [c: string]: any }; A = { b: 'hello', whatever: 2020 };
3. Type Guard
Type Guards can help us make it clearer what variables’ types will be during runtime. For example, a function that takes a parameter that both can be a string and a number like, (param: number | string)
, we can’t know which type it’ll be during runtime. But by using typeof
or instanceof
we can. Firstly, we can use typeof
as a type guard:
const func = (param: number | string) => { if (typeof param === 'string') { // typeguard // it must be a string! } // is either a string or number }
Secondly, we can use instanceof
, this is because classes will return [Object object]
with typeof
:
class A { A1 = 100 }; class B { B1 = 123 }; const doSomethingWithClass = (c: A | B) => { if (c instanceof A) { c.A1 // OK c.B1 // ERROR } else { // it must be B c.A1 // ERROR c.B1 // OK } }
4. Literals as types
Literal types allow us to declare the exact value that a string
, boolean
, or number
has to have. It’s common to combine literals with union types.
let x: 'im a string'; x = 'hello'; // ERROR // union type type Direction = 'north' | 'south' | 'east' | 'west'; let b: Direction = 'north'; // OK b = 'west'; // OK b = 'estt' ; // ERROR
When using objects, the compiler will infer it to be a string instead of a literal type, so you can then use type assertion:
const obj1 = { b: 'x'}; // { b: string } const obj2 = { b: 'x' as 'x'}; // { b: 'x' }
Closing Remarks 🏆
This article did not cover all there is to know about TypeScript, but you should now be somewhat comfortable to start using TypeScript. Hopefully, some questions emerged in your mind when reading the article, like "what's the difference between a type alias and an interface?" or "how do I use arg spread with functions?".
I recommend reading the TypeScript Handbook to learn more about TypeScript.