Mastering TypeScript: Exploring Literal, Template, and Recursive Types
Written on
Understanding Literal Types
In TypeScript, literal types define specific values that variables or function parameters can assume, unlike broader types like string or number that permit any value of that category. Literal types can be applied to strings, numbers, and booleans. To establish a literal type, you simply denote the exact value you wish to allow. For instance, the following Greeting type illustrates a literal type for the string "hello world":
type Greeting = "hello world"; // "hello world"
After defining a literal type, it can be employed for variables and function parameters. For variables, TypeScript ensures that the assigned value matches the declared type. Assigning a different value will trigger an error:
let greeting: Greeting = "hello world";
// Type '"Hello, World"' is not assignable to type '"hello world"'.
let helloWorld: Greeting = "Hello, World";
Moreover, when a variable is declared using const, TypeScript recognizes this value as a literal type:
const greeting = "Hello world";
// const greeting: "Hello world"
Literal types can also be integrated with union types, allowing a variable to accept multiple specific values. Here's a real-world example:
type HttpStatusCode = 200 | 201 | 400 | 401 | 404 | 500;
function handleResponse(statusCode: HttpStatusCode) {
switch (statusCode) {
case 200:
console.log("Success!");
break;
case 201:
console.log("Created!");
break;
case 400:
console.log("Bad Request");
break;
case 401:
console.log("Unauthorized");
break;
case 404:
console.log("Not Found");
break;
case 500:
console.log("Internal Server Error");
break;
}
}
handleResponse(200); // "Success!"
handleResponse(404); // "Not Found"
In this case, the statusCode parameter is limited to the values 200, 201, 400, 401, 404, and 500, ensuring type safety in our code.
Exploring Template Literal Types
Template literal types enable the creation of complex types by merging and manipulating string literal types through template string syntax. This feature is particularly advantageous when dealing with string literals in TypeScript. The template string syntax includes placeholders, denoted by ${}, which can be replaced with actual values at runtime. For example:
type Color = "red" | "green" | "blue";
type CSSBorderProperty = border-${Color}-1px;
// "border-red-1px" | "border-green-1px" | "border-blue-1px"
Here, we defined a new type called Color, which can only be one of "red", "green", or "blue". Using template literals, we established the CSSBorderProperty type by combining "border-" with a Color value and "-1px", resulting in possible values like "border-red-1px", "border-green-1px", or "border-blue-1px".
To further clarify, consider this example:
type ButtonClassName<Prefix extends string> = ${Prefix}-button;
const primaryButton: ButtonClassName<"primary"> = "primary-button";
const secondaryButton: ButtonClassName<"secondary"> = "secondary-button";
console.log(primaryButton); // "primary-button"
console.log(secondaryButton); // "secondary-button"
// Type '"invalid"' is not assignable to type '"primary-button"'
const invalidButton: ButtonClassName<"primary"> = "invalid";
The ButtonClassName type is particularly beneficial for ensuring type safety when defining UI component class names and styles. This approach helps prevent errors associated with using class names specific to certain button types or components.
We can also leverage template literal types to define directory and file name types:
type Path<Dir extends string, File extends string> = ${Dir}/${File};
const imagePath: Path<"images", "cat.jpg"> = "images/cat.jpg";
const dataPath: Path<"data", "file.json"> = "data/file.json";
console.log(imagePath); // "images/cat.jpg"
console.log(dataPath); // "data/file.json"
// Type '"invalid/file.txt"' is not assignable to type '"images/cat.jpg"'
const invalidPath: Path<"images", "cat.jpg"> = "invalid/file.txt";
Attempting to assign "invalid/file.txt" to the invalidPath variable will yield an error because this value doesn't correspond to the type Path<"images", "cat.jpg">.
Recursive Types Explained
Recursive types are defined in terms of themselves, either directly or indirectly, which makes them particularly useful for modeling recursive or nested data structures like trees, linked lists, or complex JSON objects. Let's examine some examples:
type MakeReadOnly<Type> = {
readonly [Key in keyof Type]: MakeReadOnly<Type[Key]>;
};
type Employee = {
personalInfo: {
firstName: string;
lastName: string;
age: number;
contactDetails: {
email: string;
phone: string;
};
};
employmentInfo: {
position: string;
department: string;
manager: string;
};
projects: {
name: string;
startDate: string;
endDate: string;
}[];
};
const employee: MakeReadOnly<Employee> = {
personalInfo: {
firstName: "John",
lastName: "Doe",
age: 30,
contactDetails: {
email: "[email protected]",
phone: "555-555-5555",
},
},
employmentInfo: {
position: "Software Engineer",
department: "Engineering",
manager: "Jane Smith",
},
projects: [
{
name: "Project A",
startDate: "2023-01-15",
endDate: "2023-02-28",
},
{
name: "Project B",
startDate: "2023-03-10",
endDate: "2023-04-20",
},
],
};
// Cannot assign to 'firstName' because it is a read-only property.
employee.personalInfo.firstName = "Jane";
// Cannot assign to 'name' because it is a read-only property.
employee.projects[0].name = "Project Scandal";
The MakeReadOnly type accepts an object type as input and recursively generates a new type where all properties are read-only. By utilizing mapped types and recursion, it evaluates each property of the original object type and marks it as read-only.
The Employee type is a complex object with nested properties for personal details, employment information, and projects. The MakeReadOnly expression transforms the original Employee type into a new type where every property is immutable, thus preventing any modifications post-initialization.
Exploring Permission Structures
What occurs when a property of an object type has a type that refers to the same object type? Let's analyze this further.
type Permission = {
name: string;
subPermissions?: Permission[];
};
const permissions: Permission = {
name: "root",
subPermissions: [
{
name: "read",
subPermissions: [{ name: "readFiles" }, { name: "readDatabase" }],
},
{
name: "write",
subPermissions: [{ name: "writeFiles" }, { name: "writeDatabase" }],
},
],
};
function checkPermission(permission: Permission, target: string): boolean {
if (permission.name === target) {
return true;}
if (permission.subPermissions) {
for (const subPermission of permission.subPermissions) {
if (checkPermission(subPermission, target)) {
return true;}
}
}
return false;
}
const hasWriteDatabasePermission = checkPermission(permissions, "writeDatabase");
console.log(hasWriteDatabasePermission); // true
const hasDeletePermission = checkPermission(permissions, "delete");
console.log(hasDeletePermission); // false
In this example, we define a Permission type, which includes a permission name and an optional list of sub-permissions. This structure models a hierarchical permission tree, where the root permission encompasses read and write permissions.
The checkPermission function traverses the permission tree to verify the existence of a target permission name, returning true if found and false otherwise. The evaluation for hasWriteDatabasePermission results in true, confirming the presence of the "writeDatabase" permission, while hasDeletePermission yields false, as that permission is not included.
I trust this article proves beneficial to you. Happy coding!
If you have any inquiries, feel free to share in the comments.
If you appreciate this type of content and wish to support me, consider buying me a coffee or clicking the clap 👏 button below to show your encouragement. Your support is crucial for me to continue producing more content — thank you!