Understanding the TypeScript's type system and some must-know concepts

In this lesson, we are going to learn about the fundamentals of TypeScript and how TypeScript manages types. This lesson includes topics such as type assertion, type interference, type unions, type guards, structural typing, and other important concepts that you should absolutely know about.

Type Inference

In TypeScript, when initializing a variable with a value, we do not necessarily need to provide the data type for the variable. The TypeScript compiler is smart enough to deduce (infer) the type by looking at the type and shape of the value we are assigning to the variable in the declaration.

In the above example, we have declared a few variables without an explicit type but with an initial value. When you hover over a variable, TypeScript provides you the interfered type which I have shown you in the comments.

If you take a look carefully, you can see TypeScript was able to tell the function argument types and return value type based on the default argument value and the return statement. This is some clever stuff.

The process of deducing or inferring type from the initial value is called type inference. TypeScript can infer the type of a variable or a constant from its initial value, type of an argument from its default value, and the return type of a function from the return value. TypeScript can also infer types in other situations as we will see in the next sections.

TypeScript, however, is not wise enough to infer complex types. For example, it won’t be able to tell if an object is a type of interface by looking at the shape of the object. We need to provide that information beforehand. However, that is not necessary and we will learn in a bit why that’s the case.

Type Assertion

In most programming languages, type casting transforms a value from one format to another format. For example, a string value such as "3" can be converted to a number value 3 using parseInt function. This is also called type conversion since we are converting a value of a one data type to a value of another data type.

Type Assertion on the other hand is a compile-time feature. In TypeScript, we use type assertion to instruct the TypeScript compiler to treat a value as a different type than it originally is. For example, you can ask the TypeScript compiler to treat 3 as a string and TypeScript will treat it as a string giving you fancy autocomplete and IntelliSense. It doesn’t impact the type of the value at runtime since it doesn’t transform or mutate the value.

This is quite helpful when the type of a value is any but we know for sure that the type of the value is something else and we want all the fancy auto-complete and IntelliSense features provided by the IDE for that value. Type assertion can be used whenever you want but with care.

In TypeScript, we use value as Type syntax to tell the compiler to treat the value value as the type of Type. We can also use <Type>value syntax which does the same thing. We might need to use parenthesis in type assertion syntax in some situations such as (<number>someNumStr).toFixed(n) or (someNumStr as number).toFixed(n).

In the above example, the getStuff function returns a value of type any. The any type is one of the most uninteresting types since it doesn’t carry any information about the value. Hence when we call toFixed() method on apple which is a string at runtime but any at compile-time, the TypeScript compiler doesn’t warn us since for it any can have any shape.

The above program compiles but when you run this program, it obviously crashes with some TypeError errors as shown in the above example. However, we can fix this by using type assertions.

In the modified example above, we have explicitly asserted that the type of apple is string and type of pi is a number before calling the type-specific methods. Using this information, the IDE and the TypeScript compiler know what they are dealing with.

Type assertion is very helpful when it comes to type unions which is an abstract type and we will look into this in a bit. Type assertion gives a lot of power but with great power comes great responsibility. Tricking the TypeScript compiler into believing something (the type of a value) which is not in real life (at runtime) can be devastating.

Literal Types

We have seen string, number, boolean and other primitive types as well as interface, function abstract types. As we have just learned, TypeScript can infer the type from the initial value. So far, we have used let keyword to declare a variable, but is the story the same for the const keyword?

In the above example, we have defined constant s which has a string value and constant n which has a number value. If you hover over these entities, you will see something peculiar. Their inferred types seem weird. They are displayed in the comments above.

With var or let, the TypeScript compiler expects these values to change during the program execution. Hence types like string or number seems more suitable. However, a constant will never change, therefore having these collective types really doesn’t make any sense.

Hence TypeScript uses the value itself as a type which can you can see in the comments of the above example. These are called literal types since the type itself is the literal value assigned.

These are also called unit types since they represent a single value out of an infinite set of literal values. We can define literal types for string, number and boolean, however, boolean can only have two unit types.

This isn’t just in the case of const declaration though. You can literally enforce a variable, function argument, or function return value to have a literal type. Let’s see an example of this.

In the above example, we have defined a function executeSafe that always returns 0 which could be the status code of the task execution. This means the task always succeeds with 0 status code.

So instead of giving the return type of number, we have given return types of 0. Later down the program, we tried to compare if the return value of this function is equal to 1 which is illegal since this condition will never happen.

TypeScript compiler complains where a unit type is expected but a variable of a collective type such as string is provided, even though the variable has the value of unit type. This is because the variable can contain any value at the runtime. To solve this, we need to use type assertion, as explained below.

Literal types are more useful while defining a set of possible literal values. This can be done using a type union as described below.

💡 TypeScript prefers the unit type over a collective type in some situations. For example, if you pass a string value such as 'hello' as an argument which is a type of string, TypeScript uses its literal type 'hello' as the type for the function call. This might be later coerced into something broader such as string or any based on the parameter type of the function. This process of narrowing a type from a collective type to a unit type is called type narrowing.

Type Union

A type union is a union of two or more types. An easy way to understand unions is to imagine a set of possible literal types. For example, if gender variable has to be 'male', 'female' or 'other', then using string type for it would mean that we’ll need to do value check at runtime.

In the above program, we have written a function setGender whose sole purpose is to accept a gender value of type string and do something with it. But since gender can be only one of these three values, we need to check if the user has provided the inappropriate value at runtime.

To avoid this, we can use literal types. The individual literal types for the gender argument are 'male', 'female' and 'other'. A single unit type can’t help here since we need gender argument value to be the of type 'male' OR 'female' OR 'other'. This is where the union type comes in.

The pipe (|) operator combines one or more types together to form a new type. This is a kind of logical OR operation but with types. Now, we can modify the above program using the union of these literal types.

Now in the modified program, the gender argument has the union type of 'male' | 'female' | 'other'. With this information, the TypeScript compiler will fail to compile the program since the setGender function call was made with the 'true' argument value which has the type of 'true' (type narrowing) and it is not present in the specified union type. This also helps us eliminate unnecessary code to verify the value of the gender argument at runtime since the program won’t compile otherwise.

A union type can be created from almost all types. For example, if the type of a variable can be either string or a number in the runtime, then you can create string | number type which will allow a number or string value.

A union type can also be created from two or more interfaces. A union of interfaces can be used to let a value pass by which has the shape of either of the interfaces in the union. However, things can get a little complicated.

In the example above, we have defined Student interface and Player interface. These interfaces have property name in common. The printInfo function accepts an argument of type Student or Player.

This program will fail to compile since we are trying to access property marks on the argument person which could either be Student or Player at runtime and property marks does not exist on the Player. That’s not the case with property name though since it exists on both types.

We can solve this issue is using type assertion. We can instruct the TypeScript compiler that the argument value at runtime will be of a particular type, however, that’s a bold move. This is explained below.

In the above example, the (person as Student) type assertion coerce the type of person from Student | Player to Student. But if we pass an argument that does not have marks property, the result could be devastating.

You can obviously write a better logic to handle such a scenario but in the end, you would need to use the type assertion syntax to instruct the TypeScript compiler what type you are expecting.

Discriminated Unions

However, TypeScript provides a neat logic to discriminate types in a union automatically without having to use type assertion. What we need is a common field in all the interfaces of a union that can be used to distinguish that type. The type of this field must be a unique literal type.

In the above program, we have used the field type in the interface as a discriminant to provide TypeScript compiler the information to discriminate types in the union. However, we need to use a type guard (explained later) to enable that.

In this case, we have used switch statement as a type guard that identifies the type of interface based on person.type which is the discriminant. The TypeScript compiler uses this information to discriminate the type and provides the correct type inside case blocks as shown in the comments. You would be able to see these types when you hover over the person argument in a case block. This also brings you the magic of IntelliSense.

The keyof operator

TypeScript provides the keyof operator which creates a union of string literal types from the keys of an interface. This is useful if a value should be one of the keys of an object, as explained in the following example.

Type union is quite helpful when a value can have more than one type at runtime. However, type union syntax can get long and hard to manage. Hence, it is better to create a type alias as shown below.

type Person = Student | Teacher | Coach

Now you can use Person type instead of writing Student | Teacher | Coach every time. Here the Person is a type alias.

Type Guards

In the previous examples, we have used the switch/case statement to resolve the type of person argument which could either be the Student or the Player interface. We had to use a discriminant field using which each case block scope gets the correct type of the person argument.

Using the discriminant field value, the TypeScript compiler was able to narrow down the type of person from the union of Student | Player to Student or Player. The process of narrowing down the type of a value from a set of possibilities to a concrete type is called type narrowing and the expression that makes this possible is called a type guard.

A type guard’s job is to provide a definite type of a value in some scope. We can avoid unnecessary and dangerous type assertions just to compile the program if we know how to use type guards. Using type guards, the TypeScript compiler will be able to resolve the type of a value on its own and it will be safe to execute the program at runtime.

Using a discriminant field

As we saw earlier, we can use an interface field of literal type as a discriminant in a switch/case statement to discriminate the type of a value from a set of possibilities (union). Here, the switch statement forms a type guard using the discriminant field.

Using in operator

If types in a union have a property that never exists in the other types, then the value of that property can be used to distinguish the type. We generally use in keyword in JavaScript to check if the object has a property.

If we have if/else statement that checks if the value has a property using the in operator, the TypeScript compiler narrows down that type in that if block, provided that the property is unique among the given types in union.

In the above example, we have the Student, Player, and Racer interface that describes the shape of an object. As we can see, the Student interface has marks property that does not exists in the other two. Similarly, the Player and Racer has score and points properties respectively of the same nature.

The printInfo function accepts person argument of type Student | Player | Racer which means person can be either of these at runtime. Here, we can use a type guard to discriminate the type of person.

We have used if/else block and in operator to narrow down the type of person from the union. Each if block gets the type of person based on the name of the property we are comparing it against.

The TypeScript compiler eliminates the resolved type from the union as it proceeds down the if/else statement. Hence when it reaches the else block, the union only has Racer left in it. Hence the type of person in the else block is Racer.

The type of person argument outside if/else statement remains the type of union unless a return statement is added in the if block. If we add a return statement in a if block, then we know for sure that code beneath it will only reach if person has the type of union minus whatever types have been resolved so far. Hence, the else block was not needed in the above example if every if block had a return statement in it.

Using instanceof operator

In the above example, our interfaces have a distinct property that can be used as a discriminant in a in operator type guard. However, in some cases, objects have similar properties or there isn’t a distinct property that can be used as a discriminant. So type narrowing using in operator can’t work.

If you are especially dealing with instances, then you can use instanceof operator to check if an object is an instance of a class.

In the above example, the printInfo function accepts the person argument of type Student or Player. The instanceof operator checks if the person is an instance of Student in the if block. If this check validates, then the type of person in this block is Student. In the else block, the person has the type of Player since it’s the only type left in the Student | Player union after the first if narrowing.

Using typeof operator

You can also discriminate types based on their type in JavaScript at runtime. As we know, we can use the typeof operator to check the type of a value in JavaScript. If we use if/else block with these operators, the TypeScript compiler will be able to narrow the types.

In the above example, we have a function printMarks that accepts the marks argument which could either be a string or a number which are JavaScript native types. Therefore, if we check the type of this property value using the typeof operator (which returns the native type of a value) in the if block, TypeScript can discriminate the type of the property in that if block.

TypeScript can also discriminate the type of a property of an interface using the same principles as shown below. In this case, person.marks have a definite type in each if or else blocks.

User-defined Type Guards

In situations where none of the above type guards can be implemented, there we can use type guard using is keyword provided by the TypeScript. The is keyword evaluates the actual type of the value against a type.

In the above example, we have created a predicateString function that returns true if arg argument is a type of string. The return type of this function is arg ``is`` string which acts an indicator for the TypeScript compiler to coerce type of value to string if the return value is arg is string.

The arg is string expression is called type predicate. In the printInfo function, we have used the predicateString function in the if/else statement. Since the return type of predicateString function is a type predicate, the TypeScript compiler can discriminate the type of marks based on the return value, just like a type guard using typeof operator.

💡 TypeScript can also use value == null expression for type narrowing. If you had a value of type null | Student then you can use this expression in if block and the type of value in the else block will be Student automatically.

Type Intersection

In the Interfaces lesson, we saw interfaces inheriting from other interfaces. Using this feature, we can combine two or more shapes together which is great for mixins pattern. However, creating a new interface by extending two or more interfaces is not always practical.

Like unions, where a value can have a shape one of the given type in that union, intersection combines the shapes of two or more types together.

In the example above, we have created the Person interface which contains firstName and lastName properties. The Player interface represents the score property and Student interface represents the marks property.

In a typical scenario, to represent an object that contains firstName, lastName and score property, you would create a new interface, probably an empty one that extends both Person and Player.

However, TypeScript provides & operator that combines two types together and returns a new type with properties of both the types. You can save these new types in an alias or use the same expression to represent a new type like what we have done in the example above.

The question that immediately pops in our mind is, what if there is a property that is common between two interfaces? Also, can I intersect primitive data types like number & string together? Let’s take a look.

In the above example, Person and Player have a common gender property, however, one has the string type and other has the number type. The TypeScript compiler doesn’t complain about this and it combines the two shapes together. However, the type of gender property is set to never.

This is because when two interfaces intersect, their common properties also intersect. Therefore, the resultant interface has a single property of the type resulted from the intersection between their original types. Therefore, the gender property has the string & number type after the intersection.

But string & number intersection doesn’t make any sense since there is no value in the world that represents this intersection. Since there isn’t value that can be both string and number at the same time, this condition will never happen. Therefore, TypeScript automatically provides the never type to the property instead of string & number.

💡 If we have an intersection between a union and primitive data type then that union will be the result of the intersection.

Structural Typing

TypeScript, in a nutshell, is a type system for writing safe and better JavaScript programs. When we provide a type to an entity or coerce types using type assertion syntax, these types do not propagate into the runtime. Once the TypeScript program is compiled to JavaScript, these types get erased, hence it is called the erased type system.

Since values in TypeScript do not possess concrete types, type checking is done by looking at the shape of the values. Let’s take a simple example.

In the above example, class Person and Student shares the firstName and lastName properties. The getFullName function accepts argument p of interface type Person and returns the full name by concatenating firstName and lastName property values.

Though we haven’t explicitly mentioned that the Student class extends Person class, TypeScript allowed monica which is an instance of the class Student as the valid value for the argument p. This is because the monica object also has firstName and lastName properties of type string and that’s what TypeScript is interested in when it validates a value against Person type.

This proves that TypeScript is a structurally typed language, also known as duck typing. As the saying goes, “If it walks like a duck, quacks like a duck, swims like a duck, then it must be a duck”. Since Student type posses the behavior of Person, TypeScript considers it as a Person.

You can apply these principles beyond classes. Since a class type in TypeScript defines an implicit interface that contains all the public properties of that class (read more), you can use the same principle with interfaces.

The above example and the previous example are exactly similar. The only difference is that monica and ross are the plain JavaScript object of interface type while in the previous example, they were the instances of the classes so their types were inferred from the class.

This behavior sometimes also called structural subtyping. When the type A has all properties of type B, then A is called as a subtype of B. This is similar to inheritance in OOP. when a class A extends class B, A is called a subclass of the class B because it has all the properties of the class B.

So in the above example, Student is a subtype of Person since it contains everything that Person has and perhaps more. However, structural subtyping is not legal in all the scenarios. Let’s see the following example.

TypeScript allows using a variable reference for substituting a subtype but complains where literal value is used. This behavior perhaps could have been implemented to avoid misleading behavior. As you can clearly see, all errors have occurred where a deliberate attempt was made.

You can read more about type compatibility from this documentation.

any vs unknown type

In the previous lesson, we learned that the type guards help the TypeScript compiler narrow down a type from a set of possibilities (union) to a definite type based on a condition that is unique to the type.

In most cases, you do not need to provide a type to an entity since it will be inferred from the initial value of that entity such as the default value of a function parameter. The same goes for the return value of a function or initial value of an object’s properties or others.

When TypeScript can’t decide which type is suitable for a variable, it implicitly set the type of that variable to any. For example, let x; expression declares a variable x but since we haven’t provided a type to it or given an initial value, the default type of variable x is set to any.

The any types only exist in TypeScript and it acts as a collective type for all the values that can exist in JavaScript runtime. That means, string, number, symbol, null, undefined and all other possible values that exist in JavaScript are also a type of any. Hence any type is sometimes called a top type or a supertype.

// type Collection = any
type Collection = string | number | undefined | any;

// type Collection = any
type Collection = string & any;

Since any is a supertype of all the JavaScript values, a union type that contains any will be narrowed to any as displayed above. Since intersection generates a type by combining the shape of two types, an intersection of any type with any returns any.

This means you can declare a variable x of type any and assign any JavaScript value to it, again and again, and the TypeScript compiler won’t complain about it. TypeScript will go one step further and let you assign a value of type any to a variable of known type. This is visualized below.

let x: any;
x = 1;
x = "one";
x = true;
let y: boolean = x; // violates subtype principle :(

This creates a big problem at runtime. Since a value of type any doesn’t have a definite shape, TypeScript will let you write a program that can use this value in whichever way possible. For example, it will let you call it like a function or instantiate it like a class. TypeScript puts all the faith in you when it comes to any. Hence a program with the type any might not work as expected or leave to disastrous results at runtime.

In the above program, since func argument of the calculate function has the type of any, you can to pass undefined as a legal value for it. As you can see, this program won’t work with undefined value since it’s not a function.

There are endless scenarios where any type can cause havoc. For example, if you expect a value x of type any to be an object at runtime, you can access a property on it such as x.a.b and TypeScript will allow it. But if x is not an object at runtime, this expression will result in an error.

Since any type doesn’t have a shape, you won’t be getting any help from your IDE for autocompletion and IntelliSense. You can, however, use type assertion syntax such as x as Person to assert type x of type any to Person.

We can also use type guard with any to narrow the type. Since any represents a collection of many types, just like a union, TypeScript can discriminate a type using a type guard.

In the above example, the person argument of the function getPosition is any. The instanceof type guard narrows the person type from any to Student in the first if block and Player in the second if block.

If you notice, we had to use else if block instead of just else. This is because TypeScript can’t magically understand that the else block contains the Player type. We are dealing with any type here and any can represent any values, and not just Student and Player. Don’t confuse it with unions.

The any type is very useful when you have no idea what shape a value might take at runtime. This generally happens when you are using third party API and TypeScript can’t figure out the types. To silence the TypeScript compilation errors, you’re compelled to use any type.

However, TypeScript introduced unknown type in TypeScript 3.0 to mitigate some of the problems associated with any. Basically, unknown is a type that tells the TypeScript compiler than the type of a value is unknown at the compile-time but it can take any shape at runtime.

Hence, unknown represents the same values that any can represent making it another top type or supertype in TypeScript. Similar to any, a union type that contains unknown will be narrowed to unknown, but any get’s a preference over unknown as shown below.

// type Collection = unknown
type Collection = string | number | undefined | unknown;

// type Collection = any
type Collection = string | number | undefined | unknown | any;

// type Collection = string
type Collection = string & unknown;

However, the unknown type behaves differently when it comes to the type intersection. An intersection of any type with the unknown returns the same type. Since intersection creates a new type by combining shapes of two types, unknown doesn’t represent any shape, hence this behavior.

You can store pretty much any JavaScript value in a variable of type unknown including the value of type any. However, you won’t be able to store a value of type unknown in a variable of known type.

let x: unknown;
x = 1;
x = "one";
x = true;
// Error: Type 'unknown' is not assignable to type 'boolean'.
let y: boolean = x;

This happens because y can only contain the a value of type boolean and a value of type unknown can take any shape at runtime. You might argue the same with any, but that’s why unknown was introduced.

TypeScript also won’t allow you to perform operations on a value of type unknown unless a type is narrowed using type assertion or a type guard.

let x: unknown;
// Error: Property 'a' does not exist on type 'unknown'.
console.log(x.a);
// Error: This expression is not callable.
x();
// Error: This expression is not constructable.
new x();

Comparison with void and never

From the Basic Types lesson, we’ve learned that the void type represents the return value of a function that doesn’t have a return statement. Similarly, the never type represents the return type of a function that will never return any value. These types do not represent any values at runtime and these act as an aid for TypeScript’s type system.

Due to this fact, void and never do not present any values that any and unknown represents. However, in a union type containing void and never along with any or unknown, the union will be narrowed to any or unknown.

// type Collection = any
type Collection = void | never | any;
// type Collection = unknown
type Collection = void | never | unknown;

typeof Keyword

If your objects need to have a definite shape and you want to enforce that rule, using an interface is an efficient and secure option. However, we didn’t go the other way around. What if we want to create an interface from the shape of the object?

Well, you can do that using typeof keyword. This keyword acts differently when used in type annotation syntax. When typeof keyword is followed by an object, it returns the shape of that object as an interface.

In the above example, we have defined the ross object which has firstName and lastName properties. Since both these properties have string values, the TypeScript compiler creates an interface implicitly that looks like below and ross variable gets that type.

{
  firstName: string;
  lastName: string;
}

The typeof ross expression may return 'object' at runtime but when it is used in type annotation like let x: typeof ross, it returns the implicit interface type associated with the ross variable. Hence we can use this expression to enforce the type of ross on other values.

We can also create an alias from like we created Person type in the above example. This is just not restricted to objects but you can use typeof keyword to extract the type of any value, such as string or interface.

declare Keyword

While writing a frontend JavaScript application, you use third-party libraries that are available only at runtime. For example, when you import lodash from a CDN, it injects _ variable in the window object to give access to the library APIs such as _.[tail](https://lodash.com/docs/4.17.15#tail) function.

The same goes for the native APIs provided by the device or browser that are only available at runtime. When you write a TypeScript program that uses one of such library functions, the TypeScript compiler complains that such a function doesn’t exist.

// Error: Cannot find name '_'
const result = _.tail([1, 2, 3]);

We can’t blame the TypeScript compiler for that since we are trying to access a value that isn’t defined in the program. The obvious choice is to define such a value and then use it, but it will just create a new value in the current scope.

var _: any;

In the above program, we have defined the _ variable and if we access any functions from this object, the TypeScript compiler won’t complain because first, this value exists and second, its type is any.

However, at runtime, we just completely bypassed the _ global variable provided by the library and we have a _ variable in the current scope that is probably undefined. This isn’t quite what we wanted.

This is where declare keyword comes into play. If we put declare keyword before a variable declaration, it won’t create a new variable in the scope, however, it will tell the TypeScript compiler that this value exists at runtime and it has the provided type (shape).

In the above program, we have defined an interface Lodash that contains the signatures of the functions exposed by the lodash library during the runtime. Then we declared a constant _ of type Lodash which will instruct the TypeScript compiler that _ value exists during the runtime and it has the shape of Lodash interface. This program will compile just fine.

💡 If you are familiar with C or C++ programming languages, the declare keyword is similar to the extern keyword which tells the compiler to assume the definition of an entity (such as a function) is available at runtime.

The declarations of the external values using declare keyword along with their types are called ambient declarations. Normally these declarations are stored in files ending width d.ts extensions (AKA type definition files). These are normal TypeScript files and are they are imported automatically by TypeScript compiler implicitly or with the help of tsconfig.json.

TypeScript comes with a lot of ambient declarations. If you check lib directory of the standard TypeScript installation, it has type definition files for Web APIs such as DOM, fetch, WebWorker as well as type definitions for JavaScript language features. These are imported automatically.

💡 We have discussed ambient declarations, type declaration files, standard library, and how to working with external libraries in the Declaration Files lesson (coming soon).

Script vs Module

TypeScript treats source (.ts) files differently based on what their role is. A JavaScript file can act as a module or as a script.

What is a script?

A script file is imported using traditional <script> tag in the browser. All the values (such as a variable or a function) defined in the script file (on the file level) are available in the global scope (also called the **global namespace), hence you can access these values from another script file.

// main.js
var ross = { firstName: "Ross", lastName: "Geller" };
var fullname = sayHello(ross); // 'Hello, Ross'

// vendor.js
var prefix = "Hello, ";
function sayHello(person) {
  var result = prefix + person.firstName;
  return result;
}

In the above example, main.js and vendor.js are normal JavaScript files. The main.js expects the sayHello function to be available at runtime. This is provided by the vendor.js. Hence the order of their loading is important.

<script src="./vendor.js"></script>
<script src="./main.js"></script>

All the values declared in script files at the file level are available in the global scope. Hence variable prefix and function sayHello from the vendor.js is added to the global scope and becomes available in the main.js. Similarly, the variable ross and name from the main.js becomes available in vendor.js. That’s not the case with the variable result since it is defined in a function so it is restricted to the function scope only.

💡 All the values from the global scope are accessible on the window in when the program is running inside a browser (such as window.sayHello()).

When you open two or more script files in VSCode, you immediately start to see errors when the variables/constants with the same name are defined in opened files. This is because VSCode assumes that these files will be imported inside a browser and it tries to warn about global scope having the same value declarations repeated across many files.

What is a module?

A module is a sandboxed script file where values declared on the file level do not pollute the global scope unless explicitly specified. All these values are restricted to the file itself and they are not visible outside the file.

Since a module doesn’t expose values, a module can’t also see the values defined in other modules. The only way to share values between modules is by using import and export keyword.

The export keyword makes a value available for import but doesn’t add it to the global scope. So another module needs to use the import keyword to import this value. I have explained how the module works in the Module System (coming soon) lesson so let’s dive directly into an example.

// main.js
import { sayHello } from "./vendor";
export var ross = { firstName: "Ross", lastName: "Geller" };
var fullname = sayHello(ross); // 'Hello, Ross'

// vendor.js
var prefix = "Hello, ";
export function sayHello(person) {
  var result = prefix + person.firstName;
  return result;
}

In the above case, both the vendor.js and main.js are modules since they use import and export statements. In this case, the vendor.js can’t see the variable ross since it is not imported in the vendor.js and similarly, variable prefix is not available in the main.js since it is not exported from vendor.js in the first place. This is an implicit abstraction provided by modules that prevent accidental pollution of the global scope.

As we can see, a module can’t pollute the global scope in this case. The only way to add some value to the global scope is by using the window object in the case of browser or the cross-platform globalThis object.

// main.js
import { sayHello } from "./vendor";
export var ross = { firstName: "Ross", lastName: "Geller" };
var fullname = sayHello(ross); // 'Hello, Ross'
console.log(window.prefix); // 'Hello, '

// vendor.js
var prefix = "Hello, ";
window.prefix = prefix;
export function sayHello(person) {
  var result = prefix + person.firstName;
  return result;
}

In the above case, prefix will be available in the main.js through window.prefix since the vendor.js has explicitly added prefix to the global scope (window).

How does this affect TypeScript?

Well, TypeScript is a superset of JavaScript, so it has to support all the principles of JavaScript. So this behavior is expected to be the same in the TypeScript as well. However, this behavior applies to the types as well.

Let’s first create a project that contains main.ts and vendor.ts file. First, we will treat main.ts and vendor.ts as script files, so they won’t contain an import or an export statement. The simplest form of the tsconfig.json file to compile this project looks like the following. You can learn more about this file from the Compilation (coming soon) lesson.

{
  "files": ["./main.ts", "./vendor.ts"],
  "compilerOptions": {
    "outDir": "dist"
  }
}

In the above example, function sayHello from the vendor.ts is accessible in the main.ts and the type Person interface from the main.ts is accessible in the vendor.ts. Since these will be compiled as script files (due to lack of import or export statements), TypeScript assumes these files will be loaded in the browser using traditional <script> tag.

See what happens as soon as we add import or export statement in the file.

By merely adding the export {} statement (which does nothing BTW), TypeScript assumes that these will be module files. Hence, the main.ts can’t access sayHello from the vendor.ts as explained below.

If you inspect carefully, the Person interface defined in the main.ts is also not accessible in the vendor.ts since main.ts is a module. So if you want to share values as well as types between modules, you need to explicitly export and import them.

In the above case, the sayHello function is imported inside the main.ts and the type Person interface is imported inside vendor.ts.

In the above example, we tried to forcefully share the prefix variable between the vendor.ts and the main.ts but the attempt failed. When we compiled the program, the TypeScript compiler throws the following error.

Property 'prefix' does not exist on type 'Window & typeof globalThis'.

This happens because window has the type of Window interface (provided by the TypeScript’s standard library) and it doesn’t contain the declaration of prefix property. So we would need to manually add it.

In the above example, we have explicitly mentioned that the prefix property is available in the global scope using the declare global statement. We will learn more about the declare global statement and type declarations in the Declaration Files (coming soon) lesson.

💡 Above example creates a cyclic dependency between main.js and vendor.js so do not implement this example in the production unless you are using a module loader that handles these kinds of situations.

#typescript #type-system