In this post, we’ll dive straight into learning the ins and outs of TypeScript: why you’d use it, how to write it, how it works under the hood, and what types are available. In later posts, we’ll review TypeScript use cases and advice and explain how to integrate TypeScript with popular libraries we use here at HubSpot, namely React. 

 

TypeScript-JavaScriptPlusTypes

What is TypeScript?

TypeScript is a valid JavaScript program, but with types defined. It aims to detect code that will throw an exception without having to run your code. Since it can alert developers of potential issues in their IDE — long before runtime — TypeScript is referred to as a static type system. TypeScript's language services parse your typed code and emit it as JavaScript, stripping types from the resulting JavaScript.

Some of the built-in types may look familiar — boolean, string, number, undefined, and null, for example — though you can also define your own types as interfaces or types. When you assign a type to a symbol, TypeScript's language services get to work — your code will automatically be validated against that type.

TypeScript files use a .ts or.tsx extension rather than a .js or.jsx extension. TSX and JSX stand for TypeScript XML and JavaScript XML, respectively, and both are used for files that contain HTML markup.

TypeScript's relationship to JavaScript

In a syntactic respect, TypeScript is referred to as a typed superset of JavaScript. This means that any syntactically-correct JavaScript program is also TypeScript. Renaming index.js to index.ts won't change that — it simply activates type-checking on index.ts. You're still writing JavaScript, just with additional syntax (the types) TypeScript has added. This makes JavaScript-to-TypeScript migrations much less painful than migrations to entirely different languages as you're simply extending JavaScript's syntax to include types.

The type-checking process

Ordinary JavaScript runs through a syntax check and, if all the syntax is correct, your code executes. TypeScript adds an additional type-checking layer to this process, which you'll see in your IDE. If your JavaScript code passes its syntax check, the TypeScript language service runs a type check looking for potential issues. Then, your emitted JavaScript code executes as usual.

type-checking-process

As you'll see in later examples, types are only used during this type-checking process, then they're erased from the JavaScript output during transpilation. The result is run as JavaScript, meaning there's no way to inspect types at runtime.

One handy resource for witnessing this type checking is the TypeScript playground. When you eventually switch to your IDE (like VS Code), the tooling, default key commands, and functionality should be identical. On the left, you can type in TypeScript and, on the right, you'll see the output JavaScript, where types are stripped. When in doubt, use the TypeScript playground for debugging and getting acclimated with TypeScript.

typescript-playground

View the TypeScript playground »

Why TypeScript?

Benefits

By far the greatest benefit to TypeScript is its language service. It catalogs the APIs we rely on in our day-to-day as JavaScript developers, such as available DOM elements, their attributes, the function signatures needed for things like event listeners and fetch requests, and more. Then, as we are typing, its autocomplete services alert us when we, say, add an attribute that doesn't exist for a particular DOM element or overlook a null pointer exception. Developers can further customize these types for their application's needs. Popular libraries such as React, too, are fully typed to provide the same intelligent autocomplete functionality.

TypeScript provides type safety, helping you catch errors within your IDE, long before you run a site in a browser or your code hits production. By adding types, you are encouraged to be more declarative about your code's intentions. Add the language service and autocomplete features to the mix, and you've got yourself automatic in-IDE documentation. This is particularly helpful if you're writing a library that other consumers use.

TypeScript infers types from JavaScript code, a process known as type inference. For better maintainability of your codebase, rely on type inference but add annotations when TypeScript gets confused. For example, more complex cases such as a function's parameters and return values will often need types added.

JavaScript pitfalls

JavaScript is weakly typed, meaning it attaches some basic types to variables, and may silently convert between these types depending on their uses. In essence, JavaScript always makes its best guess — though that guess isn’t always what we’d expect. Without typing, developers may find themselves writing less declarative code, leaving them prone to overlooking edge cases. These oversights can lead to unexpected, unpleasant and often costly errors that go undetected until our JavaScript hits production.


Let's take the example below. What would you expect to be logged?

Due to JavaScript's quirks, you'd see 2 (a number) and '1' (a string), which is likely not what you expected. TypeScript's language service notices the problem immediately. In a TypeScript file (.ts or .tsx) file, the parameter in our increment function would have a subtle underline:


This underline hints that TypeScript has flagged a potential issue. Hover over it, and you'll see a suggestion about adding a parameter type — right now, it's automatically set to any, a catch-all type that you should try to avoid using as it forfeits type safety. TypeScript is suggesting you add a type here.

By adding a type, you are stating your expectations explicitly: I'm inputting an object of type number, expecting to output an object of type number. If there are any issues, or if I overlook anything, let me know while I'm coding in my IDE, long before this code is in production.

How to assign types

To assign a type, add a colon (:) after an object's name and specify its type:

Before After

Invalid uses will automatically be flagged by TypeScript's language services. Hover over a flagged error to see exactly what is incorrect and how to fix it. In some cases, hovering and then pressing the command key lets you see even more details. This can be wildly helpful in catching bugs and identifying JavaScript quirks long before your code hits production. If you need to add more types for a value, you can create a union of types using the union (|) symbol. Let's take a look at this in the following video.

How to declare a value's type


View source on TypeScript playground »

Additional types

Arrays

So far, we've covered the boolean, string, number, undefined, and null types. We've also mentioned any, a type you should avoid using as it forfeits type checking and helpful tooling features, as we'll see throughout this post. To declare the types of values an array should hold, you can either use the Array<T> syntax or the T[] syntax, where T is the type of values in your array. The Array<T> syntax is a generic type — we'll discuss generics later.

The two forms of syntax are functionally identical, so use whichever one your team prefers. The T[] syntax is a shorthand for the generic form (Array<T>).

Tuples

To type an array, you might be wondering if you could place the type inside of the brackets (for example, [string]). Unfortunately, that would be a very different type! When types are placed within the [] brackets, that signifies an array of fixed length, also known as a tuple.

The type [string] would mean an array of length 1 filled with a single string. For example, ['a'] would be valid, while ['a', 'b'] would not.

With tuples, you may mix types as needed:

You'll often find tuples in libraries that rely on array destructuring. For example, a React useState call, which returns an array of length 2 with 2 different types:

react-usestate-typescript-typing

Use the tooling by hovering over each item to get its type. Most libraries, like React, will expose types (for example, React.Dispatch and React.SetStateAction, as seen above) which you can import and use directly:


To explore the library's types even further, right-click a symbol and click 'Go To Definition'. This is a great way to peek behind the curtain of TypeScript's language service to see what DOM and other types are defined, as well as how they are written. When typing DOM elements, functions, and the like, try to use the 'Go To Definition' menu as much as possible to learn best typing practices.

go-to-definition-typescript

Functions

When typing functions, you'll need to keep your eye on defining two types: the parameter and the return types. In the buggy increment example we saw earlier, we want our parameter type to be a number and our return type to be a number, too. Parameter types can be added next to each parameter, while return types can be added after the parentheses, as shown below:

Edit on TypeScript playground »

Functions that don't return anything (for instance, a function that logs to the console or calls another function) should have a return type of void.

Functions that throw exceptions or fail to terminate should have a never type — the never set of values represents an empty set, or values which can never be encountered.

Most TypeScript developers recommend adding a return type whenever possible. One notable exception is when your function returns a value with a type that should be inferred from another library, as this allows the type to change if and when the library's return type changes, and will cause TypeScript to flag mistakes in your implementation instead.

Generics

Generics allow you to create type declarations that capture the types of input a user provides to further provide type-checking based on those types. This allows you type-check based on a user's input in a reusable DRY format. Generics are characterized by the <> brackets, which contain a reference to the captured type used for later type-checking.

For example, let's say we have a function that takes an argument and returns an argument of that type:

Although we could use any as the parameter and return types, that's rarely, if ever, a good idea. You would miss all mixed-type cases (for example, inputting a string could unexpectedly return a number, and that case would never be flagged). Using any opts you out of type-checking entirely, meaning you won't get access to TypeScript's helpful language services, intelligent autocomplete, or the ability to rename symbols, in many cases. In a function context, it also breaks the contract of having an expected input and output, potentially making your implementations less reliable.

Instead, what we need is to automatically capture the input type to determine what the matching output type should be. We need a generic!

To establish a generic in this case, add <T> to capture the type that's passed in. Then, while typing your parameter and return types, you can reference that T type. The T symbol is commonly used since it stands for Type, though you could use any other keyword that makes sense in your codebase (for example, ArgsType or Props).

Now that we've leveraged generics to type our parameter and return types, we can take advantage of TypeScript's language services to get helpful features like autocomplete:

Untyped Typed
untyped-autocomplete-typescript typescript-autocomplete

Once you start working with TypeScript, you'll see generics everywhere. In the below video, we'll walk through this generic function example and then talk about how to explore generic types you'll run into elsewhere, such as the generic type Array<T> from the TypeScript-provided DOM declarations:

In most cases, TypeScript will infer the type from the input. This is called type argument inference. However, in certain more complex cases, you may need to set a type explicitly. Oftentimes this will happen with functions like the map<T> array helper and React's useState<T>. When needed, hover over the function to ensure it takes a generic type, and then set a type explicitly:


Interfaces and type aliases

In TypeScript, any object can be represented by two different types: interfaces and type aliases. The syntax for these two types is slightly different:

Interface syntax Type syntax

The biggest difference between interfaces and types is that interfaces are augmentable. For example, you might be writing a library and want consumers to be able to extend an interface to suit their needs via a process known as declaration merging:

Merged interface declaration Unmergeable type alias

Both can be used as a basis for composing additional types. Interfaces can be extended with the extends keyword while types can be extended with the & symbol (or, depending on your needs, the | union symbol we saw previously). If you think of these two types in a set theory sense, extends means "subset of" while & means "intersection" (or A ∩ B, the set composed of all elements that belong to both A and B), which explains some interesting results:

Which to use largely depends on the scenario. If you're publishing type declarations for an API or shared library, it might be useful for consumers to be able to merge in new fields if and when the API or library changes, so use an interface. If you're using a type declaration in an internal project only, you may want to avoid declaration merging, so use a type alias. When composing new types, extending interfaces is more performant than intersecting types, so prefer using interfaces as discussed in the TypeScript wiki.

Enums

Last but not least, TypeScript includes another feature you may be familiar with from other languages: Enums.

Since enums are a TypeScript-specific construct. They allow a developer to define a set of named constants, as seen above and in the official TypeScript docs.

Recommendations for your first TypeScript project

Configuration (tsconfig.json)

Although you can run TypeScript from the command line (via the tsc command), most team projects use a tsconfig.json file. This shared configuration file ensures that your fellow developers and tools are all flagging the same issues as you write TypeScript. There are a number of config settings available in the TypeScript docs. As you get comfortable with TypeScript, particularly if you're refactoring an existing JavaScript codebase, I'd recommend activating the noImplicitAny and strict rules as these will alert you about untyped and potentially problematic code.

Refactoring a JavaScript codebase into TypeScript

TypeScript and JavaScript can coexist in a project as modules are incrementally migrated. JavaScript code can always import TypeScript code because TypeScript is, at the end of the day, just JavaScript with types. Albeit somewhat less gracefully, TypeScript code can also import JavaScript code when necessary thanks to safety valves like in-comment directives.

Although there is a tsconfig.json compiler option (allowJs) that allow TypeScript code to import JavaScript code throughout your project, an alternative option is to use these in-comment directives, such as @ts-ignore or @ts-expect-error, above relevant JavaScript imports:

Instead of allowlisting imports of JavaScript files in TypeScript files everywhere and suppressing helpful warnings, this will enable you to convert your codebase piece by piece, eventually removing these in-comment directives as different components and files are converted to TypeScript.

The TypeScript docs have a section on migrating from JavaScript to TypeScript that are continually evolving if and when more configuration options or other migration-related features are added.

Additional Resources

That's it! I hope you enjoyed. Looking for more before our next TypeScript posts drop?


Want to work on a team that's just as invested in how you work as what you're working on? Check out our open positions and apply.

Recommended Articles

Join our subscribers

Sign up here and we'll keep you updated on the latest in product, UX, and engineering from HubSpot.

Subscribe to the newsletter