Writing JavaScript can be difficult. The speed and simplicity of development using a weakly typed language like JavaScript has its merits, but, as whole books have attested, JavaScript isn't a perfect language. Yet JavaScript powers so much of the code we write these days, whether it's server-side (node or deno), browsers, or even application development. As JavaScript projects grow, the weak typing can get in your way. Let's talk about TypeScript as a solution to some of the limitations of JavaScript.

I expect that there are three types of developers reading this article. First, you JavaScript developers who want to understand how TypeScript works: Welcome to the article. Second, for you developers from other languages that are suspicious of JavaScript and wonder if TypeScript fixes everything in JavaScript: I'm glad you're here! For the third type of developer who already uses TypeScript and wants to find a mistake in this article: Please correct me if I get anything wrong.

Before You Start

Although it can be helpful to use the TypeScript playground (https://www.typescriptlang.org/play), you'll likely want to set up your computer and code editors for TypeScript. Although many of you will use TypeScript in other frameworks and libraries (such as Angular, Vue, and React), you might be interested in learning how to use TypeScript directly. To get started, TypeScript requires Node and NPM to be installed. To do this, download the installer at https://nodejs.org/en/download/.

Once you have Node/NPM installed, you can install the compiler (globally in this case) with NPM:

> npm install typescript -g

Now that TypeScript is installed, you can create your first TypeScript file by naming it as {filename}.ts. The compiler command is tsc (TypeScript Compiler). To compile your first TypeScript file, call the compiler with the file name:

> tsc index.ts

If the compilation is successful, it creates a new file called index.js. For common editors, TypeScript is often enabled by default. For example, in Visual Studio Code, you'll get IntelliSense and error checking without any extensions, as seen in Figure 1 and Figure 2.

Figure 1: Error checking in Visual Studio code
Figure 1: Error checking in Visual Studio code
Figure 2: IntelliSense working in TypeScript
Figure 2: IntelliSense working in TypeScript

Visual Studio works in a similar way. By default, TypeScript is enabled, as seen in Figure 3.

Figure 3: TypeScript in Visual Studio
Figure 3: TypeScript in Visual Studio

No extensions are required. It just works. The default behavior in Visual Studio is to compile TypeScript when you compile your project. It can be overzealous and try to compile every TypeScript file in any libraries you're using (e.g., NPM's node_modules). You may not be using Visual Studio to compile your TypeScript (e.g., you're using the TypeScript compiler to turn your TypeScript into JavaScript on the command line), and you may need to turn off this automatic compilation. To do this, you'll need to edit the .csproj file of your project and add the TypeScriptCompileBlocked element to disable this feature:

<Project Sdk="...">
    <PropertyGroup>
        <TargetFramework>net6.0</TargetFramework>
        <Nullable>enable</Nullable>
        <TypeScriptCompileBlocked>true</TypeScriptCompileBlocked>
        ...
    </PropertyGroup>
    ...
</Project>

Now that you're set up to use TypeScript, let's dive into language.

TypeScript and JavaScript

JavaScript is an interpreted language. That means that it's a text file that a JavaScript engine processes to make it code at runtime. If you've used JavaScript, this may seem obvious. To prevent common errors, JavaScript has a handful of tools called linters that can be run to see if any common issues are valid. This includes syntax errors but also a configurable set of rules to look for. Linters are commonly used by the editors to give hints while you write the code. By the time you're ready to use the code, you have some level of confidence that the code will work, but it doesn't always ensure that the code is working.

When I say that JavaScript is weakly typed, I mean that a variable isn't tied to a specific type when created. For example:

let a = "Shawn";
console.log(typeof a); // string
a = 1;
console.log(typeof a); // number

When the variable a is created, it's a string only because I assign it a string. Once I assign it a number, it becomes a number type. This extends to other parts of the language. For example, function parameters are all optional. So if you expect a parameter, you need to check to see if it's okay before you use it:

function writeName(name) {
    // Could be empty, null, or undefined
    if (name) {
        console.log(name);
    }
}

writeName("Bob");
writeName();

It introduces a way to ensure that the assumptions in your code have the effect of improving the quality of your code. When you're writing a small project, the benefits are likely to be small. But when you're creating a large-scale JavaScript project, this additional type checking can provide enormous benefits.

What Is TypeScript?

At its core, TypeScript is a static type checker. What that means is that compiling TypeScript adds type checking to the compilation step. It does this by adding a type system and other forward-looking features to JavaScript. The language looks a lot like JavaScript. In fact, TypeScript is just a super-set of the JavaScript language. Although created inside Microsoft, it isn't (as I've heard some people think) that it's C# for the browser. That's a whole other solution (hint: it rhymes with “laser”).

Instead, TypeScript is all about making JavaScript more scalable. Although creating languages that compile down to JavaScript isn't a new idea, TypeScript is different because JavaScript is valid TypeScript. Because TypeScript is a super-set of JavaScript, it only adds new features to JavaScript to make it easier to manage large projects. Let's see an example:

const element = document.getElementById("someObject");

element.addEventListener("click", function () {
    element.classList.toggle("selected");
});

This is valid TypeScript. What does it output?

"use strict";const element = document.getElementById("someObject");

element.addEventListener("click", function () {
    element.classList.toggle("selected");
});

Do you see the difference? Only the “use strict” is added. This simple piece of JavaScript is completely valid TypeScript. That's the magic of the language. You can opt into as little or as much of the benefits of TypeScript as you want. Let's look at another example, this time, using type information in the TypeScript:

// TypeScript

// The collection
const theList = new Array<string>();

// Function to add items
function addToCollection(item: string) {
    theList.push(item);
}

// Use the typed function
addToCollection("one");
addToCollection("two");
addToCollection(3); // TypeScript Error

The generic argument in the Array specifies that the list should only accept strings. Then, in the function, you're specifying that the function should only take a string for the parameter. Not clear here is that the push function on theList only accepts a string as well. When you use the function, the two calls with a string work fine, but the third shows an error during TypeScript compilation. How does this affect the compiled output?

// Resulting JavaScript

"use strict";
// The collection
const theList = new Array();

// Function to add items
function addToCollection(item) {
    theList.push(item);
}

// Use the typed function
addToCollection("one");
addToCollection("two");
addToCollection(3); // TypeScript Error

Although the type safety functionality in TypeScript is in the source file, none of it translates into the JavaScript. So why does it matter? The TypeScript benefits are about doing type-checking at compilation time without trying to make JavaScript into a strongly typed language. This is why it's so beneficial to large projects where the level of complexity can benefit from some level of compiler checking against the assumptions in your code.

Another benefit of TypeScript is that you can write your code without thinking about the JavaScript environment. Let's start with another small piece of TypeScript:

// TypeScript
    class Animal {
    name = "";
    age = 0;
    fed = false;
    feed(food: string) {
        this.fed = true;
    }
}

In this case, you're creating a class, which isn't supported in older versions of JavaScript. When you build TypeScript, you can specify the target JavaScript level. For example, if you build it for ECMAScript 2015, you're using that version of JavaScript classes:

// ECMAScript 2015

"use strict";
class Animal {
    constructor() {
        this.name = "";
        this.age = 0;
        this.fed = false;
    }
    feed(food) {
        this.fed = true;
    }
}

But if you target older browsers (e.g., ECMAScript 5), you'd get something quite different:

// ECMAScript 5

"use strict";
var Animal = /** @class */ (function () {
    function Animal() {
        this.name = "";
        this.age = 0;
        this.fed = false;
    }
    Animal.prototype.feed = function (food) {
        this.fed = true;
        };
        return Animal;
}());

You can see what TypeScript generates by using the TypeScript playground at https://www.typescriptlang.org/play.

At the end of the day, TypeScript can help you think less about which level of JavaScript (e.g., ECMAScript) you're writing for and opt into features that are helpful to your development environment. Enough talk! Let's dig into the features of TypeScript next.

TypeScript Features

Now that you've learned the basic ideas behind TypeScript, let's see how you'd use TypeScript in your own code. The lowest hanging fruit of TypeScript is, of course, type checking.

Type Checking

You can specify the type by annotating it like so:

let theName: string = "Shawn";

By using a colon and then the type name, you can tell TypeScript that this variable should always be a string. If you try to assign a non-string, it causes a TypeScript error. For example, if you do this:

let theName: string = "Shawn";
theName = 1;

The TypeScript compiler complains:

D:\writing\CoDeMag\TypeScript\code>tsc index.ts
index.ts:3:1 - error TS2322: Type 'number' is
not assignable to type 'string'.

3 theName = 1;
~~~~~~~
Found 1 error in index.ts:3

You don't need to specify the type because TypeScript infers the type from the assignment when you create a variable:

let theName = "Shawn";
// identical to
let theName: string = "Shawn";

In most cases, you only need to specify the type if you're not assigning an initial value. This type information can be useful in functions too:

function write(message: string) {
    // no need to test for data type
    console.log(message);
}

Putting these together means that anyone using the write function gets an error if they attempt to call the write message with anything other than a string. When creating objects in TypeScript, the types are inferred too:

let person = {
    firstName: "Shawn",
    lastName: "Wildermuth",
    age: 53
};

person.age = "55"; // Won't Work

For arrays, the array type is inferred. For example, TypeScript assumes that this array is an array of strings:

let people = [ "one", "two", "three" ];

people.push(1); // won't work

This is true of arrays of objects too. You can see this in a tooltip with the type that was inferred in Figure 4.

Figure 4: Inferring array types
Figure 4: Inferring array types

Sometimes the types are more complex than you expect. Let's talk about different type checking.

Complex Types

When you're building JavaScript, sometimes it's beneficial not to tie a type specifically. You might want to specify that you support a variety of different data types. That's where the special TypeScript type called any comes in. You can specify that any type is possible with the any type. This essentially tells the compiler that you need to use weak typing:

let x: any = "Hello";
x = 1; // works as any can change type

This is similar to using types in other scenarios (like function parameters). When you want flexibility about what's used in a use case, you could use the any type there too:

function write(message: any) {
    console.log(message);
}

write("Foo");
write(1);
write(true);

In this case, any type passed in as a message would work. But there are times when you want to constrain the types. If you want to only allow numbers and strings, you might write complex code to test the datatype with an any type, but instead you could use union types. These are types that have a pattern matching as the type checking. These types are separated by the or operator (i.e, |):

function write(message: string | number) {
    console.log(message);
}

write("Foo");
write(1);
write(true); // fails

Unlike other statically typed languages, this allows you to specify patterns for types that may not fit into the limitations of strongly typing.

Functions

Because functions are a central part of any JavaScript project, you won't be surprised that functions can contain type checking too. Let's start with a simple JavaScript function:

function formatName(firstName, lastName) {
    if (!firstName || !lastName) return null;
    return `${lastName}, ${firstName}`;
}

let fullName = formatName("Shawn", "Wildermuth");

If you define this in TypeScript, it infers that the firstName and lastName are any objects and that the function returns a string. Although this is mostly correct, it's not exactly correct. Let's type those parameters:

function formatName(firstName: string, lastName: string) {
    if (!firstName || !lastName) return null;
    return `${lastName}, ${firstName}`;
}

let fullName = formatName("Shawn", "Wildermuth");

That helps confirm that you have to send in strings for the names. But you're still inferring the return type. Let's add the return type:

function formatName(firstName: string, lastName: string) : string {
    if (!firstName || !lastName) return null;
    return `${lastName}, ${firstName}`;
}

Note that the return type isn't before the function but after the function and after a colon. Although this return isn't needed (it's inferred), you might know more than the TypeScript inference. In this case, you know that you can return a null or a string, so you can use a union type:

// Using an ellipse for consiseness
function formatName(...) : string | null {

Of course, if your function doesn't return anything, it's inferred as the void type. You can be explicit about this as well:

// Using an ellipse for conciseness
function formatName(...) : void {

Using types for parameter types and return values should expose whether you're using functions incorrectly.

Now that I've talked about types in general, let's talk about defining your own types.

Type Definitions

The first way to define a type is with the type keyword. Here, you can create a named type with a definition of what you expect. For example, you could define a type for the Person type:

type Person = {
    firstName: string,
    lastName: string,
    age: number
};

Once a type is defined, you can use it to type to define variables:

let people: Person[];

This defines the variable people as accepting an array of type Person, but it only defines the type, not initialization. For example, if you wanted to initialize it, you'd need to assign a value:

let people: Person[] = [];

Alternatively, you can use a generic declaration (much like you might know from other languages):

let people = new Array<Person>();

In any of these cases, you'll see that the TypeScript compiler is going to type check that the shape of the Person is assigned to this array:

// Fails as this shape isn't the same as Person
people.push({
    fullName: "Shawn Wildermuth",
    age: 53
});

// This Works
people.push({
    firstName: "Shawn",
    lastName: "Wildermuth",
    age: 53
});

Using type definitions simply defines the shape that's required. Note that you can still use an anonymous object here, as long as it matches the type pattern.

Let's look at some other ways to define types.

Classes

If you're coming from most major languages, you're used to using classes as the central part of your development pattern. In JavaScript, classes are a part of the modern JavaScript language, but unlike C#, Java, and others, classes represent another way to package code, but they aren't required or central to JavaScript. In either case, TypeScript supports class definitions with type checking. Although JavaScript classes aren't typed, TypeScript can provide typing:

class Person {
    firstName: string;
    lastName: string;
    age: number;

    write() {
        console.log(this.firstName + " " + this.lastName);
        }
}

let person = new Person();
person.firstName = "Shawn";
person.lastName = "Wildermuth";
person.write();

Unlike a type definition, classes represent more than just data. They represent a runtime type as well:

let isPerson = person instanceof Person;

Like other parts of JavaScript, TypeScript allows you to add type checking in the typical way you'd expect. Depending on what level of JavaScript you're compiling to, this is emitted as a JavaScript class or an object notation that was useful in ECMAScript 3. In either case, being able to define classes in TypeScript allows you to use this newer feature of JavaScript without having to think about the ultimate version of JavaScript that the user will use (such as modern browsers, Node, etc.).

Classes introduce the concept of constructors. A constructor is used to specify initialization parameters to a class. You could add a constructor to the person class like so:

class Person {

    firstName: string;
    lastName: string;
    age: number;

    constructor(firstName: string,
                lastName: string,
                age: number) {
    this.firstName = firstName;
    this.lastName = lastName;
    this.age = age;
    }

write() {
    console.log(this.firstName + " " + this.lastName);
    }
}

A constructor is just a function that accepts parameters that can be used to instantiate your object. In this case, it also reports a type error when you try to create an instance when a constructor is specifying a pattern of initialization:

// Fails
let person = new Person();
// Succeeds
let person = new Person("...", "...", 53);

One shortcut to this type of construction is that if you specify a visibility in the constructor, it creates the fields for you:

class Person {
    // No longer necessary
    // firstName: string;
    // lastName: string;
    // age: number;

    constructor(public firstName: string,
                public lastName: string,
                public age: number) {
                    // this.firstName = firstName;
                    // this.lastName = lastName;
                    // this.age = age;
    }

    write() {
        console.log(this.firstName + " " + this.lastName);
    }
}

This allows you to specify fields in a shorter form than doing all the initialization. You've seen how you can directly expose variables as fields, but TypeScript also supports the accessor syntax (similar to class properties in other languages):

class Person {
    constructor(public firstName: string,
    public lastName: string,
    public age: number) { }

    get yearsSinceAdult() {
        return this.age - 18;
    }

    set yearsSinceAdult(value: number) {
        this.age = value + 18;
    }
}

let person = new Person("..,", "...", 53);
const adulting = person.yearsSinceAdult; // 35
person.yearsSinceAdult = 25;
const age = person.age;                  // 43

This allows you to specify access to field-like access while being able to intercept the getters and setters of a property. TypeScript supports other modifiers (like protected, private, readonly, etc.) but I won't be covering them in this article.

Inheritance in TypeScript

Classes support the notion of inheritance. But in JavaScript (and TypeScript by extension) this inheritance isn't class-based but object-based. This object-based inheritance is why the language of TypeScript classes isn't exactly like other languages. For example, if you wanted to inherit from a base class, what you're really doing is extending that type using the extends keyword:

class Manager extends Person {
    reports: Person[];
}

You extend the Person type with a new type called Manager. Unless you define your own constructor, you can just use the super-class' constructor. In this case, the newly created Manager object is both a Person and a Manager:

let mgr = new Manager("..,", "...", 53);
mgr.reports.push(person);

let isPerson = mgr instanceof Person;   // true
let isManager = mgr instanceof Manager; //true

This allows you to subclass or specialize classes as necessary. Sometimes you don't want inheritance but instead, you want to specify that an object adheres to a contract (e.g., interfaces). This requires you to create an interface type that has the required fields, methods, etc. Let's create an interface for the Person object:

interface IPerson {
    firstName: string;
    lastName: string;
    age: number;
}

Instead of a class needing to extend a type, an interface guarantees that the class has certain members. You can use this by using the implements keyword:

class Person implements IPerson {

If the class was missing any of the elements of the interface, you'd get a type checking error. You can implement more than one Interface but you can't extend more than one super class. So this is perfectly acceptable as long as the object adheres to both contracts:

class Person implements IPerson, IWriteable {

You might be wondering what the difference is between an interface and type definitions. In fact, they're very similar. You can use implements with type definitions:

interface IPerson {
    firstName: string;
    lastName: string;
    age: number;
}

type IWriteable = {
    write() : void;
}

class Person implements IPerson, IWriteable {

Hopefully at this point, you're seeing patterns of usage with TypeScript that could be helpful in improving the code quality of your own projects.

Configuring TypeScript Compilation

Without a configuration file for TypeScript, it uses conservative defaults. For example, you may see errors like this:

tsc index.ts
index.ts:26:7 - error TS1056: Accessors are
only available when targeting
ECMAScript 5 and higher.

26   get yearsSinceAdult() {
~~~~~~~~~~~~~~~

index.ts:30:7 - error TS1056: Accessors are
only available when targeting
ECMAScript 5 and higher.

30   set yearsSinceAdult(value: number) {
~~~~~~~~~~~~~~~

Found 2 errors in the same file,
starting at: index.ts:26

In this case, it shows an error in your code because it's trying to compile down to the lowest common denominator (e.g., EcmaScript 3). That leads to configuring TypeScript. You can create a default configuration file in TypeScript by using the compiler:

> tsc --init

This creates a file called tsconfig.json. The configuration that this option adds shows all the possible settings and can be a bit overwhelming. You can create your own as a simple tsconfig.json file. I'll start out with an empty object with one property called include:

{
    "include": [ "index.ts" ]
}

You could also specify wildcards:

"include": [ "**/*.ts" ],

Using wildcards can get you in trouble if you're using NPM, so often you'll want to specifically exclude the node_modules directory:

{
    "include": [ "**/*.ts" ],
    "exclude": [ "node_modules/"],
}

You'll notice that if you run the compiler with no arguments, the error is the same because you haven't told TypeScript how you want to compile the code. You do this with an object called compilerOptions:

{
    "include": [ "**/*.ts" ],
    "exclude": [ "node_modules/"],
    "compilerOptions": { }
}

Most of the configuration happens in this property. For example, let's fix the error by telling TypeScript what version of JavaScript to compile to. Let's set it to EcmaScript 2015, which is good for most modern browsers:

{
    "include": ["**/*.ts"],
    "exclude": ["node_modules/"],
    "compilerOptions": {
        "target": "ES2015"
    }
}

Now if you execute the compiler (tsc), it compiles the code safely. Without making any changes, TypeScript is somewhat permissive (e.g., making any un-type definition an any type), but often, for large codebases, you'll also turn on strictness to force typing:

{
    "include": ["**/*.ts"],
    "exclude": ["node_modules/"],
    "compilerOptions": {
        "target": "ES2015",
        "strict": true
    }
}

This should give you a tiny taste of the configuration. There are a lot of options that may be required depending on your environment, but this should get you started.

Type Libraries

One of the benefits of TypeScript that may not be obvious at first is to consume something called Type Libraries. When you write TypeScript, it's checking for self-confirming type information. In other words, your classes, functions, and types will be checked within your project. What about other frameworks or libraries that you're using? If you're working within an NPM-enabled project, you might have some libraries. For example, the moment* library could be added to your project like so:

> npm install moment

I'm using moment as an example, although its use isn't necessary any longer in most JavaScript projects.

When you use the library, how does TypeScript help there? Many of these libraries have added type information in the form of Type Libraries (typically {projectName}.g.ts). In the moment project, it contains a moment.g.ts. This means that when you use moment in your own project, it knows about the interface and types from this library (even though it's JavaScript). Most editors (Visual Studio Code, Visual Studio, etc.) read these type libraries and use that information to help with IntelliSense or other hinting. The TypeScript compiler uses this type information to confirm that your code is correct as well. For example, if you wanted to parse a date with moment:

let date = moment("2025-05-05");

This looks like it's usage from JavaScript, but the type information is there (and inferred):

let date: moment.Moment = moment("2025-05-05");

This is what you'd use to be explicit about the type information. Why is this important? It means that by using TypeScript even if you aren't using the type checking, you will gain benefits in development because of the type libraries. It's a good use for TypeScript even if you aren't sold on the static analysis.

Where Are We?

TypeScript is an important language that allows you to improve your code quality in large projects. There are benefits beyond just being more stringent in your own coding. You can gain benefits even if you don't end up diving into any the advanced features of TypeScript. In addition, you'll see its use in many of the frameworks you're using or that you will eventually use. I hope I've encouraged you to take a look and see how it fits into your own projects.