Introduction
TypeScript is a statically typed superset of JavaScript, the inherently dynamically typed, high-level scripting language of the web. It bases itself on the core features of JavaScript and -- as the primary mission -- extends them with compile time static typing. It also comes with several feature extensions: perhaps most notably enums, class instance types, class member privacy and class decorators. TypeScript offers more advanced additions and considerations with respect to iterators, generators, mixins, modules, namespacing, JSX support, etc., which JavaScript developers would find different and more nuanced towards static typing -- as they get familiar with them over time.
In this post, we first shed some light on important concepts related to static typing and learn about the capabilities and advantages they offer TypeScript over JavaScript. We gained insight into the role of type definitions, type annotations and type checking using the structural type system. While doing so, we recall primitive types (number
, string
, boolean
) in JavaScript that lay the foundations of more complex type definitions in TypeScript. We also get to hear about literal types (string, array and object) and additional types (any
, unknown
, void
, etc.) that TypeScript adds to its typing toolbelt and a host of type utilities (Awaited<>
, Pick<>
, Omit<>
, Partial<>
, Record<>
, etc.) that are used to derive new types from existing ones.
Towards the latter half, we explore the tools that run the processes that facilitate static typing in TypeScript. We get a brief account of how TypeScript code is transpiled with the TypeScript compiler (tsc
) to runtime JavaScript code. We also get to understand that TypeScript's type checker is integrated to the tsc
for performing type checking and emitting errors that help developers write type-safe code by fixing type related bugs early in development phase. We get a quick rundown of the TS type checker's tooling roles: namely editor and linguistics support with code completion, quick fix suggestions, code formatting / reorganizing, code refactoring, and code navigation. We also find out how all the features of the TypeScript compiler is integrated in VS Code with the help of background task runners -- something that offers better developer experience by helping to avoid running the tsc
repeatedly.
Towards the end, we explore notable feature extensions that TypeScript brings to the table -- particularly enums, class instance types, class member privacy and decorators. Finally, we point to implementations of more advanced features such as iterators, generators, mixins, etc.
Steps we'll cover in this post:
- TypeScript Concepts
- TypeScript Tools
- TypeScript Type Definitions / Declaration Packages
- TypeScript's Extended Features
TypeScript Concepts
TypeScript Concepts - Static vs Dynamic Typing
JavaScript is inherently dynamically typed. It means that types of values of expressions in JavaScript are set at runtime, not before that. Dynamic typing leads to different kinds of type errors and unaccounted for behaviors in JavaScript code, especially at the hands of inexperienced developers tasked with scaling an application. And as a codebase grows, maintainability becomes a major concern.
Microsoft created TypeScript to add a static typing system on top of JavaScript. It was open sourced in 2012 to help write error-proof, stable, maintainable and scalable web applications. Static typing is an implementation where types of expressions are determined before runtime. Particularly in TypeScript, static typing takes place before compilation carried out by tsc
, the TypeScript compiler.
Static typing involves three major steps: type declaration, type annotation and type checking. Type checking refers to matching and validating type conformance of the value of an expression to its annotated / inferred type with the help of TypeScript's static type checker.
TypeScript Concepts - Type Definitions
Integral to static typing is declaring proper type definitionss for entities in an application. Generally, a type can be declared with an alias or it can be an interface. Types are also generated from TypeScript enums as well as classes. These options allow developers to declare and assign consistent types to expressions in a web application and help prevent type errors and unanticipated bugs at runtime.
Primitive data types (number
, string
boolean
, null
and undefined
) that are already part of JavaScript usually lay the foundations of more complex, nested type definitions in TypeScript. TypeScript adds some more static types to its toolbelt like any
, unknown
, void
, never
, etc., to account for different scenarios.
Apart from these, TypeScript also offers a swathe of type utilities that help transform one type to another. Example type utilities include Awaited<>
, Pick<>
, Omit<>
, Partial<>
, Record<>
, etc. Such utilities are not relevant in JavaScript but in TypeScript, they are handy for deriving useful variants from a base type. Using them adds stability to an otherwise brittle JavaScript codebase and helps make large web applications easily tweakable and maintainable.
TypeScript Concepts - Type Annotation and Inference
Adding proper type annotations to expressions is perhaps the most crucial part of static typing in TypeScript. It is necessary for subsequent verification of type conformance performed by TypeScript's type checker.
Type Annotations in TypeScript
Type annotations are done explicitly on expressions using primitive data types and / or -- as mentioned above -- types defined using aliases, interfaces, enums and classes.
Target expressions for type annotation are variable declarations, function declarations, function parameters, and function return types. Annotations are also made to class fields and other members such as methods and accessors -- along with their associated parameters and return types.
Type Inference in TypeScript
Where type annotations are not explicitly added, TypeScript infers the type from the primitive type, literals or its object shape of the value itself.
Type inference may follow the below two principles:
- Best common type: where TypeScript assigns a common type that encompasses all items. This is useful when inferring a common type from an array literal with items composed of primitives. For example, for an array with items of type
number
andstring
, the inferred type is the following best common type:
const x = [0, 1, "two"]; // const x: (number | string)[]
- Contextual typing: where the type of the expression is inferred from the lexical context in which it is being declared. See an example here
TypeScript Concepts - Type Checking and the Structural Type System
A particular value of an expression is checked for validity against its annotated or inferred type by TypeScript's type checker. Type compatibility depends on whether the structure or shape of the value matches that of the annotated one. In other words, TypeScript has a structural type system.
In structural type systems, the shape of the value of an expression must conform to that of the annotated type. Besides, it can be compatible with another type that is identical or equivalent in shape.
Show example of shape compatibility in TypeScript's structural type system
For example, stereoTypicalJoe
below is typed to User
:
type User = {
username: string;
email: string;
firstName: string;
lastName: string;
};
type Person = {
username: string;
email: string;
firstName: string;
lastName: string;
};
type Admin = {
username: string;
email: string;
firstName: string;
lastName: string;
role?: string;
};
// `stereotypicalJoe` is compatible with `User`
const stereoTypicalJoe: User = {
username: "stereotypical_joe",
email: "joe_stereo@typed.com",
firstName: "Joe Stereo",
lastName: "Typed",
};
Thanks to TypeScript's structural type system, it is also compatible with Person
because Person
is structurally identical to User
:
// It is also compatible with `Person` which is identically typed to `User`
const stereoTypicalJoe: Person = {
username: "stereotypical_joe",
email: "joe_stereo@typed.com",
firstName: "Joe Stereo",
lastName: "Typed",
};
TypeScript also allows stereoTypicalJoe
to be compatible with Admin
type, because equivalent types ( role
being an optional property in Admin
) are compatible:
// Structural Type System allows `stereoTypicalJoe` to be compatible with `Admin` which is equivalently typed to `User`
const stereoTypicalJoe: Admin = {
username: "stereotypical_joe",
email: "joe_stereo@typed.com",
firstName: "Joe Stereo",
lastName: "Typed",
};
Structural compatibility in TypeScript is practically the appropriate option for annotating and validating the types of JavaScript expressions, because shapes of objects in web applications remain identical or similar while their composition varies greatly. This is in contrast to the nominal type system, which decides type conformance strictly based on specifically named types.
In JavaScript, since types are tagged to a value at runtime, there is no static type annotation involved. Hence the need for it in TypeScript.
TypeScript Tools
tsc
, the TypeScript Compiler
The central tool that TypeScript uses for running processes related to static typing is the TypeScript compiler, tsc
. The ultimate job of the TS compiler is to transform statically typed code to execution-ready pure JavaScript code. This means that the type definitions and annotations that we add inside a .ts
or .tsx
file, are erased after compilation. In other words, the output .js
or .jsx
files are not passed the static typing we add to corresponding TS files in the first place.
Show example of TS transpiling
For example, the following TypeScript code:
function greet(person: string, date: Date) {
console.log(`Hello ${person}, today is ${date.toDateString()}!`);
}
greet("Joe", new Date());
compiles to the JS script below:
"use strict";
function greet(person, date) {
console.log(
"Hello ".concat(person, ", today is ").concat(date.toDateString(), "!"),
);
}
greet("Joe", new Date());
Notice that the type annotations applied in the .ts
file are not output to the .js
version. But we would know from TypeScript type checker that they were type validated.
In the interim, what we get is a chance to apply a consistent type system to validate the type safety and stability of our code -- something we cannot perform with JavaScript alone.
TS Compiler Configuration
The TS compiler is generally configured with a default set of standard options inside the tsconfig.json
file. And we can tailor it according to our needs and preferences. Particularly from the compilerOptions
object, we can set options for a target ECMAScript version, type checking, modules scanning, experimental features, etc.
Show an example `tsconfig.json` file
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"experimentalDecorators": true
},
"include": ["src"]
}
TypeScript Type Checker
The tsc
is equipped with a static type checker that checks the value of an expression against its annotated or inferred type. It emits type error(s) in the event of failed matches. The primary goal of the type checker is to check for type conformance. It's broader goal is to ensure type safety of a code base by catching and suggesting corrections for all possible kinds of type errors during development.
Type errors can originate from typos, change of API interfaces, incomplete/inaccurate type definitions, incorrect annotations, incorrect assertions, etc.
The errors are output by the compiler to the command line console when a file is run with tsc
command:
tsc hello.ts
The TS type checker keeps track of the information from type definitions and annotations in our codebase. It then uses these descriptions to validate structural/shape conformance or otherwise throw an error. Type checking is performed during code changes and before compilation runs.
TS Type Checker - Linguistic Tooling
The TS type checker keeps track of updated types information while we write our code. This allows it to catch bugs and also help us prevent them in the first place. We can correct typos, type errors and possible non-exception failures as they get caught and emitted by the type checker.
Based on the type descriptions it keeps, the type checker can also help us with code completion, quick fix suggestions, refactoring, formatting/reorganization, code navigation, etc.
TypeScript Support in VS Code
Microsoft's Visual Studio Code, or VS Code in short, comes with integrated support for the TypeScript compiler, its static type checker, and other linguistic tooling mentioned above. It runs the tsc
and the static type checker with the help of watch mode background task runners in the code editor.
For example, VS Code's IntelliSense runs the TypeScript static type checker in the background to provide code completion on typed expressions:
Below are a list of other major VS Code features that aid everyday TS developers:
Type errors: type errors are highlighted inside the editor. When hovered over, we can see the error warnings. Error highlighting helps us to investigate and fix the errors easily.
Quick fix suggestions: associated quick fix suggestions are provided when hovered on a error. We can use the editor's automatic fix or fix them ourselves.
Syntax errors and warnings: syntax errors are highlighted by VS Code's lingguistic support for TypeScript. It helps fix them instantly.
Code navigation: we can quickly navigate a particular code snippet by looking it up using shortcuts. Code navigation helps us avoid errors by gaining clarity on lookup.
VS Code also provides formatting/reorganizing, refactoring debugging features as well. All these features help us write error-free, stable code that contributes to an application's maintainability and scalability.
TypeScript Type Definitions / Declaration Packages
TypeScript comes with built-in definitions for all standard JavaScript APIs. They include type definitions for objects types like Math
, Object
, browser related DOM APIs, etc. These can be accessed from anywhere in the project without the need to import the types.
Apart from built-in types, application specific entities have to be typed properly. It is a convention to use separate type declaration files in order to differentiate type definitions from features code.
TypeScript Type Declaration - .d.ts
Files
Application specific type declarations are usually collected in a file suffixed with .d.ts
. It is common to declare all types and interfaces inside a single index.d.ts
file and export them from there.
Show an example of TypeScript type declaration file
export interface User {
username: string;
email: string;
firstName: string;
lastName: string;
}
export interface Person {
username: string;
email: string;
firstName: string;
lastName: string;
}
export interface Admin {
username: string;
email: string;
firstName: string;
lastName: string;
role?: string;
}
While annotating, we have to import each type inside the file we are using it:
import { User, Person, Admin } from "src/interfaces/index.d.ts";
const anyUser: User = {
username: "stereo_joe",
email: "joe@typed.com",
firstName: "Joe",
lastName: "Typed",
};
const typicalJoe: Person = {
username: "typical_joe",
email: "joe_typical@typed.com",
firstName: "Joe Structure",
lastName: "Typed",
};
const stereoTypicalJoe: Admin = {
username: "stereotypical_joe",
email: "joe_stereo@typed.com",
firstName: "Joe Stereo",
lastName: "Typed",
};
TypeScript Type Packages - DefinitelyTyped @types
Existing JavaScript libraries that support a TypeScript version offer type definition packages for use with TypeScript. DefinitelyTyped is a popular type definition repository that hosts collections of type definition packages for major JS libraries that also support TypeScript.
Type definition packages hosted by DefinitelyTyped are scoped under the @types/
directory. We can get the necessary definition packages with npm
or yarn
. For example, the react
type definitions can be included inside node_modules
with the following scoped package:
npm install --save-dev @types/react
Then, we can use the types inside our app. It is important to notice that unlike in the case of using .d.ts
declaration files, we don't need to import the types from their node_modules
files. That's because they are made available automatically by npm
.
TypeScript's Extended Features
Apart from implementing a static type system to produce error-proof, maintainable and scalable codebase, TypeScript extends the language with additional features with their respective syntax. TypeScript enum
s are such an addition that injects type objects to JavaScript runtime. TypeScript classes are implemented in a way that produces types. Some aspects of TS classes, such as member privacy and decorators are implemented in different ways than in JavaScript.
In the following sections, we try to understand how they contrast.
TypeScript Extensions - Enums
TypeScript adds a special data structure called enum
to address the need for data organization around an intent -- like defining a set of categories or a strict set of subscription options. enum
s are not available in JavaScript. In TypeScript, an enum introduces a representative JavaScript object to runtime. It can then be accessed by subsequent code to get its values or that of its properties.
Enums serve as efficient replacement of objects that would otherwise be stored and accessed from a database table. They inherently generate types that can be used to annotate expressions or object properties. You can find an in-depth example of TS Enums in this refine.dev blog post.
TypeScript Extended Features - Classes as Types
In TypeScript, classes also generate types from its constructor function. An instance of a given class is by default inferred during static type checking the type generated from the class. For a detailed example, check this refine.dev blog post.
In contrast, class instances in JavaScript are tagged their types during runtime.
TypeScript Extended Features - Class Member Visibility
TypeScript supports class member visibility since ES2015. It implements member privacy at three levels: public
, protected
and private
. Privacy of class members in TypeScript is modeled according to prototypal inheritance based object oriented concepts.
For example, public
members are accessible from everywhere, as in instances, the class itself as well as subclasses. protected
members are not accessible from instances, they are only accessible from the class and its subclasses. private
members are only accessible from inside the class.
In contrast, starting ES2022, JavaScript implements class property privacy using the #
syntax. Property access in JavaScript classes can either be totally public or totally private. In addition, a class property's privacy in JavaScript is not inheritable, because it is not accessible from the prototypal chain of the class instance.
TypeScript Extended Features - Class Decorators
Decorators are a design pattern in programming. In any programming language, the decorator pattern gives an interface to add behavior to a class instance dynamically without affecting other instances. It is possible to easily implement decorators with JavaScript, especially with clever functional programming. Not to mention with TypeScript.
However, TypeScript has a Stage 3 proposal that brings class decorators with a special @
syntax. It is quite distinct from conventional decorator implementation with JavaScript and TypeScript. Class decorators in TypeScript allow classes and their members to be decorated with runtime behaviors. The class itself can be decorated, so can fields, methods and accessors be. For a comprehensive example of class decoratiors, please check this blog post on refine.dev.
TypeScript Advanced Features
Other extended features in TypeScript are related to iterators and generators, mixins, modules, namespacing, JSX support, etc.
Most of these advanced concepts require special considerations to facilitate relevant static typing. For example, TypeScript iterators and generators have to implement the Symbol.iterator
property and they should be annotated the Iterable
interface. TypeScript mixins make use of complex relationships between class instance types, subtypes, multiple interface implementations, class member privacy, prototypal inheritance and class expressions. Too much, yet too good...
Getting a good grasp of these advanced TypeScript features require gradual adoption of the language as a whole, as we aim to keep our codebase type-safe, stable and our web application maintainable and scalable.
Summary
In this post, we compared TypeScript with JavaScript. While trying to make the comparisons, we gained useful insights into how the two types of systems and their implementations differ. We got a high-level view of the role of the TypeScript compiler, the mechanisms of the static type checker in catching and preventing type errors, and the linguistic tooling that helps developers write error-free and highly stable application code. We also contextualized some of TypeScript's notable extended features that differ from those in JavaScript in light of TypeScript's static type system.