TypeScript Fundamentals: Understanding Types and Interfaces
TypeScript adds powerful features to JavaScript, enabling developers to write more reliable and maintainable code. One of the core features of TypeScript is its type system, which helps catch errors at compile time. In this article, we'll explore TypeScript's types and interfaces, understand their usage, and see how they can improve your development workflow. We'll also include code examples and tests using Jest to illustrate their practical application.
Basic Types
TypeScript provides several basic types that you can use to annotate your variables and function parameters.
Boolean
This basic data type expresses a simple true
/false
value.
let isDone: boolean = true;
Number
In JavaScript, as well as TypeScript, all numbers are either floating point values or BigIntegers. The floating point numbers are designated the type number
, while BigIntegers are designated the type bigint
. In addition to decimal and hexadecimal literals, TypeScript also supports binary and octal literals introduced in ECMAScript 2015.
let decimal: number = 11;
let hex: number = 0xf00d;
let binary: number = 0b1011;
let octal: number = 0o744;
let bigNumber: bigint = 100n;
String
Text data in JavaScript is generally represented as data of type string
. This is also the case in TypeScript.
let color: string = "blue";
color = 'red';
let sentence: string = `Hello ${name}!`;
Array
JavaScript allows an array to be an ordered collection of values, of any type. However TypeScript allows enforcing the type of data that is contained within an array. Here are the ways an array may be declared in TypeScript:
let list: number[] = [0, 1, 3];
let evens: Array<number> = [2, 4, 6];
Tuple
Tuples are arrays with a fixed number of elements whose types are known. You can use tuples to express values such as the positions x, y, z in Cartesian coordinates, or longitude, latitude values representing geographic data.
let x: [string, number];
key = ["key", 1001]; // OK
key = [10001, "key"]; // Error
Enum
Many high level languages such as C# and Java has had enumerated data types since their inception. JavaScript does not. TypeScript adds this feature which allows a more friendly way to assign names to a set of numeric values.
enum Color {
Red,
Green,
Blue,
}
let c: Color = Color.Green;
It is also possible to manually set the values in the enum:
enum Position {
First = 1,
Second = 2,
Third = 3,
}
let myPosition: Position = Position.Second;
The names of the enum values can be accessed by indexing into the enum as shown below:
enum Color {
Red,
Green,
Blue,
}
let name: string = Color[2];
// Displays 'Green'
console.log(name);
Unknown
At times we may need to declare a variable whose type isn't known at compile time. For instance the value may come from dynamic content such as value returned from an API. In this instance, we want to instruct the compiler as well as future readers of the code that this variable could be anything, so we give it the unknown
type.
let notSure: unknown = 4;
notSure = "maybe a string instead";
// OK, definitely a boolean
notSure = false;
unknown
types cannot be directly assigned to another variable without performing a type check, such as a type guard:
declare const maybe: unknown;
// 'maybe' could be a string, object, boolean, undefined, or other types
const aNumber: number = maybe;
// Type 'unknown' is not assignable to type 'number'.
if (maybe === true) {
// TypeScript knows that maybe is a boolean now
const aBoolean: boolean = maybe;
// So, it cannot be a string
const aString: string = maybe;
//Type 'boolean' is not assignable to type 'string'.
}
Any
Sometimes you may need to opt-out of type checking all together, either when the type data is not available, or would take an inordinate amount of time to specify. In these scenarios, we label the variables with any
type.
declare function getValue(key: string): any;
// OK, return value of 'getValue' is not checked
const str: string = getValue("myString");
The any
type is a powerful way to work with existing JavaScript source code, and gradually opt-into the TypeScript typing system.
Unlike unknown
, variables of type any
can access arbitrary properties, without any errors. This is similar to how JavaScript has been handling object properties.
All properties of an any
type object are also of type any
:
let looselyTyped: any = {};
let d = looselyTyped.a.b.c.d;
// `d` is of type `any`
Null and Undefined
On their own, undefined
and null
are not terribly useful. However, null
and undefined
are subtypes of all other types. Therefore, you can assign null
or undefined
to a variable of any other type, such as a string
.
However, when using the strictNullChecks
flag in tsconfig.json
, applications of null
and undefined
are limited. This helps avoid many common errors.
Never
The never
type represents the type of values that never occur. As an example, never
is the return type of a function that always throws an error, or never returns.
// Function returning never must not have a reachable end point
function error(message: string): never {
throw new Error(message);
}
// Inferred return type is never
function fail() {
return error("Something failed");
}
// Function returning never must not have a reachable end point
function infiniteLoop(): never {
while (true) {}
}
Object
object
is a type that represents composite types, i.e. anything that are not number
, string
, boolean
, bigint
, symbol
, null
, or undefined
.
declare function create(o: object | null): void;
// OK
create({ prop: 0 });
// Argument of type 'number' is not assignable to parameter of type 'object'
create(42)
About Number
, String
, Boolean
, Symbol
and Object
The types Number
, String
, Boolean
, Symbol
and Object
are not equivalent to their lowercase versions. These types do not refer to the language primitives, and should almost never be used as types.
function reverse(s: String): String {
return s.split("").reverse().join("");
}
reverse("hello world");
Type Annotations
You can explicitly specify the types of variables and function parameters to make your code more predictable and readable.
function greet(name: string): string {
return `Hello, ${name}!`;
}
let greeting: string = greet('Alice');
Type assertions
Type assertions are a way to tell the compiler "trust me". A type assertion is like a type cast in other languages, but it performs no special checking or restructuring of data. It has no runtime impact, and is simply used by the compiler.
Assertions have two forms.
as
-syntax:
let someValue: unknown = "Hello World!";
let strLen: number = (someValue as string).length;
"angle-bracket" syntax:
let someValue: unknown = "Hello World!";
let strLen: number = (<string>someValue).length;
:::tip
When using TypeScript with JSX, only as
-style assertions are allowed.
:::
Interfaces
Interfaces are used to define the shape of objects, making your code more expressive and enforcing a contract for your objects.
interface User {
id: number;
name: string;
email: string;
}
function getUser(): User {
return {
id: 1,
name: 'John Doe',
email: 'john.doe@example.com'
};
}
let user: User = getUser();
Type Aliases
Type aliases are similar to interfaces, but they can also represent primitive types, union types, and more.
type ID = number;
type Status = 'active' | 'inactive';
interface Product {
id: ID;
name: string;
status: Status;
}
function getProduct(): Product {
return {
id: 101,
name: 'Laptop',
status: 'active'
};
}
let product: Product = getProduct();
Combining Types and Interfaces
You can combine types and interfaces to create more complex types.
type Address = {
street: string;
city: string;
zipcode: string;
};
interface Customer {
id: ID;
name: string;
address: Address;
}
function getCustomer(): Customer {
return {
id: 202,
name: 'Jane Doe',
address: {
street: '123 Main St',
city: 'Somewhere',
zipcode: '12345'
}
};
}
let customer: Customer = getCustomer();
Type Guards
TypeScript type guards are functions or expressions that allow you to narrow down the type of a variable within a conditional block. They help the TypeScript compiler understand the specific type of a variable, enabling more precise type checking and preventing runtime errors.
Here are some common types of type guards and examples to illustrate their usage:
typeof
Type Guards
The typeof
operator can be used to narrow down types to primitive types such as string
, number
, boolean
, and symbol
.
function printValue(value: string | number): void {
if (typeof value === "string") {
console.log(`String value: ${value}`);
} else if (typeof value === "number") {
console.log(`Number value: ${value}`);
}
}
printValue("Hello, World!"); // String value: Hello, World!
printValue(42); // Number value: 42
instanceof
Type Guards
The instanceof
operator can be used to narrow down types to specific classes.
class Dog {
bark() {
console.log("Woof!");
}
}
class Cat {
meow() {
console.log("Meow!");
}
}
function makeSound(animal: Dog | Cat): void {
if (animal instanceof Dog) {
animal.bark();
} else if (animal instanceof Cat) {
animal.meow();
}
}
const dog = new Dog();
const cat = new Cat();
makeSound(dog); // Woof!
makeSound(cat); // Meow!
User-Defined Type Guards
User-defined type guards use a function to check whether a value is of a specific type. These functions return a boolean value and have a specific return type called a type predicate.
interface Bird {
fly(): void;
}
interface Fish {
swim(): void;
}
function isBird(animal: Bird | Fish): animal is Bird {
return (animal as Bird).fly !== undefined;
}
function makeAnimalMove(animal: Bird | Fish): void {
if (isBird(animal)) {
animal.fly();
} else {
animal.swim();
}
}
const bird: Bird = {
fly: () => console.log("Flying")
};
const fish: Fish = {
swim: () => console.log("Swimming")
};
makeAnimalMove(bird); // Flying
makeAnimalMove(fish); // Swimming
in
Operator Type Guards
The in
operator can be used to check whether an object has a specific property.
interface Car {
drive(): void;
}
interface Boat {
sail(): void;
}
function moveVehicle(vehicle: Car | Boat): void {
if ("drive" in vehicle) {
vehicle.drive();
} else if ("sail" in vehicle) {
vehicle.sail();
}
}
const car: Car = {
drive: () => console.log("Driving")
};
const boat: Boat = {
sail: () => console.log("Sailing")
};
moveVehicle(car); // Driving
moveVehicle(boat); // Sailing
Combining Type Guards
You can combine different type guards to handle complex type narrowing scenarios.
interface Admin {
admin: true;
manage: () => void;
}
interface User {
admin: false;
view: () => void;
}
function isAdmin(person: Admin | User): person is Admin {
return person.admin === true;
}
function isUser(person: Admin | User): person is User {
return person.admin === false;
}
function handlePerson(person: Admin | User): void {
if (isAdmin(person)) {
person.manage();
} else if (isUser(person)) {
person.view();
}
}
const admin: Admin = {
admin: true,
manage: () => console.log("Managing")
};
const user: User = {
admin: false,
view: () => console.log("Viewing")
};
handlePerson(admin); // Managing
handlePerson(user); // Viewing
Conclusion
Understanding types and interfaces is fundamental to mastering TypeScript. By defining explicit types and using interfaces to describe the shape of your objects, you can write more reliable and maintainable code. These practices also help catch errors early in the development process, improving your overall development workflow.
If you're new to TypeScript, I encourage you to start by annotating your variables and functions with types and gradually move on to defining interfaces for your objects. As you become more comfortable with TypeScript's type system, you'll find that it significantly enhances your productivity and code quality.
For further reading and in-depth tutorials, check out these resources:
Happy coding!