DEV Community

Cover image for TypeScript 102 -  Beyond The Basics
Max Feige
Max Feige

Posted on • Originally published at maxfeige.dev

TypeScript 102 -  Beyond The Basics

Beyond The Basics Image

The seemingly endless sea of TypeScript tutorials you'll find online do a great job of providing resources to both beginners and advanced users, but what about those in the middle? If you know the basics but aren't quite at the advanced level yet, then this series of articles is for you. In it we'll be exploring some tools and techniques that'll take your TypeScript code to the next level!

This guide assumes that you're already familiar with the basics of TypeScript and JavaScript. You should understand the concepts of types, interfaces, the as keyword, functions, and classes, as we'll be building on all of these fundamentals.

Let's start in the shallow end: the building blocks TypeScript gives us and what we can do with them.

Union (|) and Intersection (&) Types

Union and Intersection Venn Diagram

Unions and intersection types are incredibly useful for enhancing the flexibility of your code.

A union type, denoted by |, allows a variable to be one of several types. This is useful when we want our code to accommodate a variety of data types. Here's an example using interfaces:

In this case, a person is either a Teacher or a Coder. The work method, which is shared by Teacher and Coder, is valid since it's present regardless of which type person is. Methods specific to one interface, however, may not exist in person, and that's where TypeScript steps in to stop us from trying to use them. Without additional work, TypeScript will only allow us to use properties that it knows will exist.

We can also use unions with primitive types:

In this example, we're allowing myVar to be a number or a string. TypeScript ensures we can only use properties or methods common to all types in the union, thus preventing us from using the string-only method toLowerCase.

When we use a union, we're establishing that any variable assigned to our type is going to satisfy the properties of at least one of the provided types. TypeScript will then go further and only allow us to access properties and methods common to all types - at least if we don't give TypeScript more information to narrow the type with! Don't worry if that sounds complicated, we'll dive into narrowing shortly.

Next up is the intersection! Intersections, denoted by &, complement the union and allow us to merge multiple types into one. A variable declared using an intersection must have the features of all combined types. Let's illustrate this with an example:

As you can see, an intersection allows us to create a new type by combining the overlapping properties of both interfaces. Intersection types can be used with primitives as well, as shown here:

Since the primitive types string and number are mutually exclusive, an intersection is impossible. The intersection results in the type never, which we'll talk more about soon. For now, just know that it symbolizes a type that can't exist.

If we try to intersect two types with conflicting properties we're also greeted by never:

In this example intersection, id would be required to be both number and string simultaneously, which is impossible. TypeScript sees this and assigns it the type never, effectively preventing us from using our newly created type.

Overall, intersections allow us to construct complex types from simple building blocks, and TypeScript will ensure that any values we assign to an intersection satisfy all types in the intersection.

Utilizing both unions and intersections effectively gives you the ability to easily mix and match your existing types with ease, saving you the effort of manually creating similar types.

Type Narrowing

One of TypeScript's most important features is type narrowing, a process by which the type of a variable is (wait for it) narrowed using, for example, a conditional. This gives TypeScript more information about what type we're providing it. Let's expand on a previous example to see this in action:

Our function specialPrint takes an item, which is either a string or a number. The function will then check if item is a string and will use either toLowerCase or toFixed based on the result. Thanks to the conditionals the methods are contained in, TypeScript will recognize that the code can only reach these methods when item is the correct type and will bypass its limitations on methods that aren't shared between every type in the union.

Ever have a variable in limbo waiting for user input or for a network request to finish? These variables normally start out as undefined, which can be a common source of headaches for many JavaScript developers. TypeScript gives us the tools to tackle this issue by using a combination of unions and type narrowing. This approach helps us write code that's clean and bug-free!

In this example, any Person could be a Teacher, Coder, or null. Since null has no methods, we cannot directly call person.work(). Instead, we must ensure that person is not null with a simple conditional. Once we've done this, TypeScript recognizes that person is type Teacher | Coder and we're able to call the work method safely.

This is where the power of type narrowing really shines. By using type checks, we can ensure that our code behaves as expected and is free of runtime errors. This makes our code safer, easier to understand, and easier to maintain. There are many more ways we can employ type narrowing in TypeScript, but that's an article for another day. For now, just know that TypeScript has an eye on your conditional statements and will understand which types are possible within them.

Any, Unknown, and Never

TypeScript has a few special types: any, unknown, and never. Let's start by taking a look at any:

Thanks to any, the above code is perfectly acceptable and will not yield any errors! A variable of type any has the flexibility to be set to whatever value you want, and other variables of different types can be set to it without issue. In other words, TypeScript does no type-checking on a variable of type any. While you might occasionally want a variable to be treated like this, type-checking is one of the best reasons to use TypeScript in the first place. This means that it's usually best to use unknown instead.

Although you can set an unknown variable to whatever value you want, other variables cannot be directly set to it. Instead you must explicitly cast its type, like so:

Using unknown is preferred to using any because it encourages us to explicitly pick when and how we convert it to another type.
Finally, let's look at never:

The never type is restrictive in that it prevents the assignment of any values to it. Similarly, no other variables can be set to a variable of type never. This type symbolizes an impossibility; something that should never happen. One common place we may see never is in functions that should always throw errors and never return a value:

I've made a short summary chart for these three types:

Recommended usage of any,unknown,never chart

All three of these serve distinct purposes, each with different balances between type safety and flexibility. While any provides maximum flexibility, it comes at the cost of losing the type safety benefits given by TypeScript. Because of this, you should instead use the unknown type and explicitly tell TypeScript to cast your variables. Lastly, the never type will always give us an error when we try to interact with it. We use never when we want to tell TypeScript that there should be no scenario in which an outcome occurs, and that you want an error to be thrown if it does. We'll go deeper into some advanced uses of never in future articles.

Literal Types

TypeScript also gives us literals, another powerful feature that represents exact values and can be a specific instance of a string, number, or boolean. Let's look at one:

Here we created a variable called userRole with the type "basic". Because of this, the variable myUserRole can no longer be assigned to any other string. A literal on its own doesn't seem that useful, but when combined with some of the other functionalities we've explored we can construct some useful types:

Through a combination of unions and literals, we've created a type that could have one of three possible values. This could come in handy!

When we create a variable with let or var, TypeScript typically assigns it a general type (like string, number, or boolean). Alternatively, if we declare a variable with const, TypeScript will assume that its type is a literal. Let's check out an example of this:

We can also tell Typescript to behave this way when declaring with let or var by using as const.

as const is a helpful way to make sure that your types are as specific as possible. Additionally, we can use as const on objects to ensure that the fields are specific:

Overall, literals are an excellent way to limit the values our variables can contain. You can see that as const has changed the type of our object's fields into literals. It's also added the keyword readonly, though. What's happening here?

Readonly

No writing

TypeScript introduces several new keywords, one of which is readonly. This keyword allows us to specify parts of an object as mostly unchangeable. Let's investigate:

Now TypeScript will give us an error whenever we try to change (or "write to") the id variable, hence the name readonly. The only time a readonly property can be set is during initialization, or when the object is created. This concept also applies to classes:

Just like with our interface example, we can't change the id field after it's been initialized. For a class, this means we can only change id in the constructor. After the constructor has finished executing, TypeScript will no longer allow us to change that field.

Finally, readonly can be applied to arrays:

This prevents us from adding, removing, or changing any element in our array, but we can still assign a new array to the variable containing it.

readonly is an excellent keyword to use in large codebases, as it allows you to explicitly choose when something should be observable but unchangeable. This bars people from accidentally modifying data they shouldn't be able to, which in turn prevents bugs and clarifies the intent of your code.

Conclusion

The features explained in this article are among the most frequently used in TypeScript, and by thoughtfully utilizing them yourself you can construct less error-prone systems and maintain JavaScript's inherent flexibility. Remember, TypeScript isn't about making your life harder! It's about making your code safer, more maintainable, and easier to understand.
As you continue to use and explore TypeScript, I encourage you to try out these concepts and discover for yourself how you can apply them to your own projects. In the next article, we'll dive deeper into some of the common utility types given by TypeScript. Stay tuned!

Top comments (0)