TypeScript’s advanced type system offers powerful tools beyond basic types, enabling us to write more robust and maintainable code. These techniques are crucial for scaling applications and building reliable services, especially when dealing with complex data transformations as we do at MisuJob, where our AI-powered job matching processes 1M+ job listings daily.
Advanced TypeScript Patterns for Robust Applications
At MisuJob, we’re constantly seeking ways to improve the reliability and clarity of our codebase. TypeScript’s advanced type features are essential for this, helping us catch errors early and ensure data integrity when we aggregate data from multiple sources. This post explores three key patterns: branded types, discriminated unions, and template literal types, demonstrating how they can enhance type safety and code clarity.
Branded Types: Adding Semantic Meaning to Primitives
Sometimes, primitive types like string or number lack the necessary context. For instance, a string could represent an email address, a user ID, or a product name. Branded types (also known as nominal typing) allow us to distinguish between these conceptually different types, even if they share the same underlying representation.
Here’s how we can define a branded type for EmailAddress:
type EmailAddress = string & { readonly __brand: unique symbol };
const createEmailAddress = (email: string): EmailAddress => {
if (!email.includes('@')) {
throw new Error('Invalid email address');
}
return email as EmailAddress;
};
function sendEmail(email: EmailAddress, message: string) {
// Logic to send email
console.log(`Sending email to: ${email} with message: ${message}`);
}
const userEmail = createEmailAddress("[email protected]");
sendEmail(userEmail, "Hello!");
// This would cause a type error:
// sendEmail("notanemail", "Hello!");
In this example, EmailAddress is a string with an added __brand property (a unique symbol). We can only create instances of EmailAddress through the createEmailAddress function, ensuring that the value adheres to our validation logic. Attempting to pass a plain string to a function expecting an EmailAddress will result in a compile-time error, greatly improving type safety. This technique is particularly useful when working with IDs, currencies, or any other primitive value that requires specific validation or semantic meaning. At MisuJob, we use branded types extensively for representing user IDs and job posting IDs, preventing accidental mixing of different ID types.
Discriminated Unions: Simplifying Complex Type Logic
Discriminated unions (also known as tagged unions or algebraic data types) are a powerful way to represent values that can be one of several different types. Each type in the union has a common “discriminant” property, allowing TypeScript to narrow down the type based on the value of this property.
Consider a scenario where we’re handling different types of search queries on MisuJob: a keyword search, a location-based search, and a company-based search. We can represent this using a discriminated union:
type KeywordSearch = {
type: 'keyword';
query: string;
};
type LocationSearch = {
type: 'location';
location: string;
radius: number;
};
type CompanySearch = {
type: 'company';
companyName: string;
};
type SearchQuery = KeywordSearch | LocationSearch | CompanySearch;
function handleSearch(query: SearchQuery) {
switch (query.type) {
case 'keyword':
console.log(`Performing keyword search for: ${query.query}`);
break;
case 'location':
console.log(`Performing location search for: ${query.location} within ${query.radius} km`);
break;
case 'company':
console.log(`Performing company search for: ${query.companyName}`);
break;
default:
// TypeScript will ensure this is unreachable
const _exhaustiveCheck: never = query;
return _exhaustiveCheck;
}
}
handleSearch({ type: 'keyword', query: 'Software Engineer' });
handleSearch({ type: 'location', location: 'Berlin', radius: 50 });
handleSearch({ type: 'company', companyName: 'MisuJob' });
The type property acts as the discriminant. TypeScript uses this property to infer the specific type within each case of the switch statement. This ensures type safety and allows us to handle each type of query appropriately. The _exhaustiveCheck at the end of the switch is a useful trick to ensure that all possible cases are handled. If we add a new type to the SearchQuery union without updating the handleSearch function, TypeScript will flag an error because the _exhaustiveCheck will no longer be assignable to never. We leverage discriminated unions extensively in our AI-powered job matching engine to handle different types of job criteria and user preferences.
Template Literal Types: Generating Types from Strings
Template literal types allow us to create new string literal types by concatenating other string literal types. This can be extremely useful for defining types based on patterns or formats.
For example, we might want to define a type for different types of error codes:
type Environment = "development" | "staging" | "production";
type ErrorCodePrefix = "ERR";
type ErrorCode = `${ErrorCodePrefix}-${Environment}-${number}`;
const validErrorCode: ErrorCode = "ERR-production-123";
// const invalidErrorCode: ErrorCode = "ERROR-prod-123"; // Type Error
function logError(code: ErrorCode, message: string) {
console.error(`[${code}] ${message}`);
}
logError("ERR-development-42", "Something went wrong in development");
Here, ErrorCode is a template literal type that combines ErrorCodePrefix, Environment, and a number. This ensures that all error codes follow a consistent format. This pattern is especially valuable when dealing with APIs that return structured error codes or when defining types for configuration values. We find this particularly useful when defining types for API endpoints and versioning schemes.
Another practical application involves constructing database query types dynamically based on table names and column types. Imagine a scenario where you need to enforce type safety when constructing SQL queries. While not a replacement for a proper ORM, template literal types can help prevent simple errors:
type TableName = "users" | "products" | "orders";
type Column<T extends TableName> =
T extends "users" ? "id" | "name" | "email" :
T extends "products" ? "product_id" | "product_name" | "price" :
T extends "orders" ? "order_id" | "user_id" | "order_date" :
never;
type SelectStatement<T extends TableName, C extends Column<T>> = `SELECT ${C} FROM ${T}`;
const validQuery: SelectStatement<"users", "name"> = "SELECT name FROM users";
// const invalidQuery: SelectStatement<"users", "price"> = "SELECT price FROM users"; // Type Error
function executeQuery<T extends TableName, C extends Column<T>>(query: SelectStatement<T, C>): void {
// In a real-world scenario, this would execute the SQL query.
console.log(`Executing query: ${query}`);
}
executeQuery("SELECT id FROM users");
This example shows how template literals combined with conditional types can enforce basic SQL query structure at compile time. Although limited in scope, it demonstrates the potential for leveraging these features to catch errors early in the development process. This is particularly valuable when dealing with complex data transformations and reporting pipelines, ensuring that the queries we generate are syntactically correct before they even reach the database.
Salary Data and the Importance of Precise Types
Understanding salary expectations is crucial for both job seekers and employers. At MisuJob, we provide insights into salary ranges based on location, experience, and skills. Let’s consider a simplified example focusing on Software Engineer salaries across different European countries.
| Country/Region | Junior (0-2 years) | Mid-Level (3-5 years) | Senior (5+ years) |
|---|---|---|---|
| Germany (Berlin) | €50,000 - €65,000 | €70,000 - €90,000 | €95,000 - €120,000+ |
| United Kingdom (London) | £45,000 - £60,000 | £65,000 - £85,000 | £90,000 - £110,000+ |
| Netherlands (Amsterdam) | €48,000 - €62,000 | €68,000 - €88,000 | €92,000 - €115,000+ |
| Sweden (Stockholm) | 450,000 - 600,000 SEK | 650,000 - 850,000 SEK | 900,000 - 1,100,000+ SEK |
| France (Paris) | €42,000 - €55,000 | €60,000 - €75,000 | €80,000 - €100,000+ |
Precise types are essential for representing this kind of data accurately. Using branded types for currencies, for example, can prevent errors when comparing salaries across different countries. Discriminated unions can be used to represent different salary structures (e.g., base salary + bonus vs. fixed salary). And template literals can be used to enforce a consistent format for salary ranges.
Real-World Example: Validating API Responses
Imagine we’re building a feature that displays job recommendations based on a user’s profile. The API response might look like this:
[
{
"jobId": "123e4567-e89b-12d3-a456-426614174000",
"title": "Senior Software Engineer",
"company": "TechCorp",
"location": "Berlin, Germany",
"salaryRange": "€95,000 - €120,000+",
"tags": ["TypeScript", "React", "Node.js"]
},
{
"jobId": "890a1234-5678-9012-3456-1234567890ab",
"title": "Frontend Developer",
"company": "WebSolutions",
"location": "Amsterdam, Netherlands",
"salaryRange": "€68,000 - €88,000",
"tags": ["JavaScript", "React", "HTML", "CSS"]
}
]
We can use advanced TypeScript patterns to ensure that this data is handled correctly:
type JobId = string & { readonly __brand: unique symbol };
type JobRecommendation = {
jobId: JobId;
title: string;
company: string;
location: string;
salaryRange: string;
tags: string[];
};
function validateJobRecommendation(data: any): JobRecommendation {
// Perform validation logic here to ensure data integrity
// This is a simplified example. In a real-world scenario,
// you would want to perform more thorough validation.
if (typeof data.jobId !== 'string' || !isValidUUID(data.jobId)) {
throw new Error('Invalid jobId');
}
const jobId = data.jobId as JobId; // Assertion after validation
return {
jobId: jobId,
title: data.title,
company: data.company,
location: data.location,
salaryRange: data.salaryRange,
tags: data.tags,
};
}
function isValidUUID(uuid: string): boolean {
// Basic UUID validation regex
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(uuid);
}
// Example usage (assuming you fetch data from an API)
const apiResponse = [
{
"jobId": "123e4567-e89b-12d3-a456-426614174000",
"title": "Senior Software Engineer",
"company": "TechCorp",
"location": "Berlin, Germany",
"salaryRange": "€95,000 - €120,000+",
"tags": ["TypeScript", "React", "Node.js"]
}
];
try {
const validatedRecommendation = validateJobRecommendation(apiResponse[0]);
console.log("Validated Job Recommendation:", validatedRecommendation);
} catch (error) {
console.error("Error validating job recommendation:", error);
}
In this example, we use a branded type for JobId to ensure that job IDs are properly validated. We also define a JobRecommendation type to represent the structure of the API response. The validateJobRecommendation function performs validation logic and returns a validated JobRecommendation object. This helps us catch errors early and ensures that we’re working with valid data throughout our application.
Conclusion
By incorporating branded types, discriminated unions, and template literal types into our development workflow, we at MisuJob significantly improve the robustness and maintainability of our code. These patterns allow us to express complex data structures and validation rules more clearly, leading to fewer errors and a more reliable AI-powered job matching experience for our users. Investing in these advanced TypeScript techniques pays dividends in the long run, allowing us to scale our platform effectively and continue to provide valuable career insights to professionals across Europe.

