Learn TypeScript: Type Assertions, Type Narrowing, & Function

You may want to read this article in Bahasa Indonesia version: Mengenal TypeScript: Type Assertions, Type Narrowing, & Function
TypeScript offers various ways to manipulate and narrow down data types. This article will discuss three important concepts: Type Assertions, Type Narrowing, and Function Type Signatures.
Type Assertions
Type assertion is a way to tell TypeScript that we, as developers, know a more specific type than what TypeScript can infer. It’s like saying “trust me, I know what I’m doing 🤣.”
Type Assertion Syntax
There are two syntaxes for type assertion:
/** Angle bracket syntax (not recommended in JSX) */
let someValue: any = "Hello, TypeScript";
let strLength: number = (<string>someValue).length;
/** 'as' syntax (recommended) */
let someValue2: any = "Hello, TypeScript";
let strLength2: number = (someValue2 as string).length;
Type Assertion Use Cases
Type assertions are useful in several situations:
/** 1. When working with the DOM */
const myInput = document.getElementById("myInput") as HTMLInputElement;
console.log(myInput.value); // Without assertion, TypeScript doesn't know this is an input
/** 2. When working with API responses */
interface User {
id: number;
name: string;
email: string;
}
async function fetchUser() {
const response = await fetch("https://api.example.com/user");
const data = await response.json();
return data as User; // Ensuring data has the User structure
}
/** 3. When working with union types */
function processValue(value: string | number) {
if (typeof value === "string") {
return (value as string).toUpperCase();
}
return value.toFixed(2);
}
Assertion vs Type Casting
It’s important to remember that type assertion in TypeScript is not type casting as in other programming languages. Type assertion doesn’t change the data structure at runtime, it only tells TypeScript about the type it should be.
/** Ini akan berhasil di compile time tapi gagal di runtime */
const obj = { name: "John" };
const user = obj as User; // TypeScript tidak komplain
// console.log(user.email); // Runtime error: Cannot read property 'email' of undefined
Safer Assertions with unknown
For safer assertions, we can use the unknown
type as an intermediary:
/** Multi-level assertion with unknown */
interface Product {
id: string;
price: number;
}
const data: any = { id: "prod-1", price: "100" }; // Note that price is a string
// This would error at compile time:
// const product = data as Product; // Error: price must be a number
// With unknown as intermediary:
const product = data as unknown as Product; // TypeScript doesn't complain
console.log(product.price.toFixed(2)); // Runtime error: price.toFixed is not a function
Type Narrowing
Type narrowing is the process of refining types from a more general type to a more specific type. This is especially useful when working with union types.
Type Guards with typeof
/** Type guard with typeof */
function formatValue(value: string | number): string {
if (typeof value === "string") {
// Here TypeScript knows value is a string
return value.toUpperCase();
}
// Here TypeScript knows value is a number
return value.toFixed(2);
}
console.log(formatValue("hello")); // "HELLO"
console.log(formatValue(42.123)); // "42.12"
Type Guards with instanceof
/** Type guard with instanceof */
class Customer {
name: string;
email: string;
constructor(name: string, email: string) {
this.name = name;
this.email = email;
}
sendEmail(): void {
console.log(`Sending email to ${this.email}`);
}
}
class Admin {
name: string;
permissions: string[];
constructor(name: string, permissions: string[]) {
this.name = name;
this.permissions = permissions;
}
grantAccess(): void {
console.log(`${this.name} has ${this.permissions.join(", ")} permissions`);
}
}
function processUser(user: Customer | Admin): void {
console.log(`Processing user: ${user.name}`);
if (user instanceof Customer) {
// Here TypeScript knows user is a Customer
user.sendEmail();
} else {
// Here TypeScript knows user is an Admin
user.grantAccess();
}
}
const customer = new Customer("John", "john@example.com");
const admin = new Admin("Alice", ["read", "write", "delete"]);
processUser(customer); // "Processing user: John" "Sending email to john@example.com"
processUser(admin); // "Processing user: Alice" "Alice has read, write, delete permissions"
User-Defined Type Guards
We can also create custom type guard functions with predicate types:
/** User-defined type guard */
interface Car {
make: string;
model: string;
year: number;
}
interface Motorcycle {
make: string;
model: string;
year: number;
type: "sport" | "cruiser" | "touring";
}
/** Type guard function with type predicate */
function isCar(vehicle: Car | Motorcycle): vehicle is Car {
return (vehicle as Motorcycle).type === undefined;
}
function processVehicle(vehicle: Car | Motorcycle): void {
console.log(`Vehicle: ${vehicle.make} ${vehicle.model} (${vehicle.year})`);
if (isCar(vehicle)) {
// Here TypeScript knows vehicle is a Car
console.log("This is a car");
} else {
// Here TypeScript knows vehicle is a Motorcycle
console.log(`This is a ${vehicle.type} motorcycle`);
}
}
const car: Car = { make: "Toyota", model: "Corolla", year: 2020 };
const motorcycle: Motorcycle = {
make: "Honda",
model: "CBR",
year: 2021,
type: "sport",
};
processVehicle(car); // "Vehicle: Toyota Corolla (2020)" "This is a car"
processVehicle(motorcycle); // "Vehicle: Honda CBR (2021)" "This is a sport motorcycle"
Discriminated Unions
Discriminated union is a powerful pattern for type narrowing:
/** Discriminated union pattern */
interface SuccessResponse {
status: "success";
data: any;
}
interface ErrorResponse {
status: "error";
message: string;
}
type ApiResponse = SuccessResponse | ErrorResponse;
function handleResponse(response: ApiResponse): void {
// The 'status' property is the discriminant
if (response.status === "success") {
// Here TypeScript knows response is a SuccessResponse
console.log(`Success: ${JSON.stringify(response.data)}`);
} else {
// Here TypeScript knows response is an ErrorResponse
console.log(`Error: ${response.message}`);
}
}
const successResponse: ApiResponse = {
status: "success",
data: { id: 1, name: "Product" },
};
const errorResponse: ApiResponse = { status: "error", message: "Not found" };
handleResponse(successResponse); // "Success: {"id":1,"name":"Product"}"
handleResponse(errorResponse); // "Error: Not found"
Function Type Signatures
TypeScript offers powerful ways to define function types, parameters, and return values.
Basic Function Types
/** Basic function type */
type GreetFunction = (name: string) => string;
const greet: GreetFunction = (name) => `Hello, ${name}!`;
console.log(greet("TypeScript")); // "Hello, TypeScript!"
Optional dan Default Parameters
/** Optional and default parameters */
function createUser(
name: string,
age?: number,
isActive: boolean = true
): object {
return {
name,
age: age ?? "Unknown",
isActive,
};
}
console.log(createUser("John")); // { name: "John", age: "Unknown", isActive: true }
console.log(createUser("Alice", 30)); // { name: "Alice", age: 30, isActive: true }
console.log(createUser("Bob", 25, false)); // { name: "Bob", age: 25, isActive: false }
Rest Parameters
/** Rest parameters */
function sum(...numbers: number[]): number {
return numbers.reduce((total, num) => total + num, 0);
}
console.log(sum(1, 2, 3, 4, 5)); // 15
Function Overloading
Function overloading allows us to define multiple types for the same function:
/** Function overloading */
// Overload signatures
function convert(value: string): number;
function convert(value: number): string;
function convert(value: boolean): string;
/** Implementation signature */
function convert(value: string | number | boolean): string | number {
if (typeof value === "string") {
return parseFloat(value) || 0;
} else if (typeof value === "number") {
return value.toString();
} else {
return value ? "true" : "false";
}
}
console.log(convert("42")); // 42 (number)
console.log(convert(42)); // "42" (string)
console.log(convert(true)); // "true" (string)
Generic Functions
Generic functions allow us to create functions that work with various types:
/** Generic function */
function identity<T>(value: T): T {
return value;
}
const num = identity<number>(42); // 42 (type: number)
const str = identity<string>("hello"); // "hello" (type: string)
const bool = identity<boolean>(true); // true (type: boolean)
// TypeScript can also infer types automatically
const auto = identity("TypeScript"); // "TypeScript" (type: string)
A more in-depth explanation of the concept of Generics in TypeScript can be read here: Learn TypeScript: Generics
Higher-Order Functions
Higher-order functions are functions that accept or return other functions:
/** Higher-order function */
type Mapper<T, U> = (item: T) => U;
function mapArray<T, U>(array: T[], mapper: Mapper<T, U>): U[] {
return array.map(mapper);
}
const numbers = [1, 2, 3, 4, 5];
const doubled = mapArray(numbers, (n) => n * 2);
const strings = mapArray(numbers, (n) => n.toString());
console.log(doubled); // [2, 4, 6, 8, 10]
console.log(strings); // ["1", "2", "3", "4", "5"]
Functions with **this**
Context
TypeScript allows us to specify the type for the this
context in functions:
/** Function with this context */
interface Counter {
count: number;
increment(): void;
decrement(): void;
getValue(): number;
}
function createCounter(): Counter {
return {
count: 0,
increment(this: Counter): void {
this.count++;
},
decrement(this: Counter): void {
this.count--;
},
getValue(this: Counter): number {
return this.count;
},
};
}
const counter = createCounter();
counter.increment();
counter.increment();
counter.decrement();
console.log(counter.getValue()); // 1
The above code example shows how TypeScript allows us to define a type for the this
context in functions. By defining this: Counter
as the first parameter (which is not actually passed to the call), TypeScript ensures that the functions are only called in the context of objects that conform to the Counter
interface. This improves type safety because TypeScript will throw an error if we try to use the method with an inappropriate this
context, for example if we try to call increment
with bind on an object that does not have a count
property.
Conclusion
Type assertions, type narrowing, and function type signatures are important features in TypeScript that allow us to write safer and more expressive code. Type assertions give us a way to tell TypeScript about the types we believe are correct, type narrowing allows us to refine types for more specific handling, and function type signatures provide powerful ways to define function contracts.
By understanding and leveraging these features, we can write TypeScript code that is more robust, has fewer errors, and is easier to maintain. The functional programming approach becomes more powerful when combined with an expressive type system like the one offered by TypeScript.