← Back to Lessons

TypeScript: Advanced Types and Configuration

Quick Review: Primitive Types
Compound Types: When Reality Is More Complicated

If you're already familiar with TypeScript's basic types, you've probably realized something: primitive types only get you so far, but when you start building real applications, you need more powerful tools. This is where TypeScript truly shines.

This article explores the features that make TypeScript more than just "JavaScript with types." These are the tools you'll use every day once you move past the "declare everything as string or number" phase.

Quick Review: Primitive Types

Before diving into advanced topics, a quick review. TypeScript includes all of JavaScript's primitive types plus a few extras:

1const message: string = "Hello TypeScript"; 2const count: number = 42; 3const isActive: boolean = true; 4 5// Arrays 6const numbers: number[] = [1, 2, 3]; 7 8// Objects with interfaces 9interface User { 10 name: string; 11 age: number; 12 email?: string; // optional 13} 14 15const user: User = { 16 name: "Ana", 17 age: 28 18};

If these concepts are new to you, it's worth checking out the introductory TypeScript article before continuing. What follows assumes you're already comfortable with these basics.

Compound Types: When Reality Is More Complicated

In the real world, things are rarely black or white. A variable isn't always "just a string" or "just a number." Sometimes it's "a string OR a number." Sometimes it's "this AND that." Compound types exist precisely for these cases.

Union Types: This OR That

Imagine you're building a function that handles the state of an HTTP request. The state could be 'loading', 'success', or 'error'. How do you type it?

You could use string, but that would allow any string, including meaningless values like 'banana'. What you really want is to say: "this variable can be one of these specific values, and nothing else."

1type Status = 'idle' | 'loading' | 'success' | 'error'; 2 3let currentStatus: Status = 'idle'; 4currentStatus = 'loading'; // ✓ valid 5currentStatus = 'pending'; // ❌ Error: TypeScript knows this isn't valid

This is a union type. The | operator means "or." It's like telling TypeScript: "trust me, this value can only be one of these things."

The beauty of union types is that TypeScript helps you handle each case:

1type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; 2 3function makeRequest(url: string, method: HttpMethod) { 4 // TypeScript knows exactly what values method can have 5 console.log(`Making ${method} request to ${url}`); 6} 7 8makeRequest('https://example.com/users', 'GET'); // ✓ 9makeRequest('https://example.com/users', 'PATCH'); // ❌ Error

Union types aren't limited to string literals. They can be any combination of types:

1type Result = number | string; 2 3function processValue(value: Result) { 4 if (typeof value === 'number') { 5 return value * 2; 6 } else { 7 return value.toUpperCase(); 8 } 9}

Here's something interesting. TypeScript is smart enough to understand that inside the if, value must be a number (because you just checked it). And in the else, it must be a string. This is called "type narrowing" and it's one of TypeScript's most useful features.

Intersection Types: This AND That

While union types say "this OR that," intersection types say "this AND that." They're defined with &:

1interface HasName { 2 name: string; 3} 4 5interface HasAge { 6 age: number; 7} 8 9type Person = HasName & HasAge; 10 11const person: Person = { 12 name: 'Carlos', 13 age: 30 14};

Why would you do this instead of just creating a Person interface with both properties? Because sometimes you want to combine interfaces that come from different places:

1interface Timestamped { 2 createdAt: Date; 3 updatedAt: Date; 4} 5 6interface Identifiable { 7 id: string; 8} 9 10interface UserData { 11 name: string; 12 email: string; 13} 14 15// Combine everything without duplicating code 16type User = UserData & Identifiable & Timestamped;

This is especially useful when working with APIs. You can have base interfaces representing common patterns (like "everything has an ID" or "everything has timestamps") and combine them as needed.

Type Aliases vs Interfaces: The Million Dollar Question

This is probably one of the most frequently asked questions: "When do I use type and when do I use interface?"

The honest answer is: for simple objects, either works fine and the choice is mostly stylistic. But there are some practical differences:

1// With interface - you can extend 2interface UserInterface { 3 name: string; 4 email: string; 5} 6 7interface AdminInterface extends UserInterface { 8 role: 'admin'; 9 permissions: string[]; 10} 11 12// With type alias - use intersection 13type UserType = { 14 name: string; 15 email: string; 16}; 17 18type AdminType = UserType & { 19 role: 'admin'; 20 permissions: string[]; 21};

General rule: If you're defining the shape of an object, especially if you plan to extend it later, use interface. For union types, complex intersection types, or more advanced types, use type.

But honestly, don't spend too much time worrying about this. Consistency in your project is more important than the specific choice.

Literal Types: More Specific Than Ever

Literal types take precision to the next level. Instead of saying "this is a string," you can say "this is exactly this string":

1let answer: 'yes' | 'no'; 2answer = 'yes'; // ✓ 3answer = 'maybe'; // ❌ No, you can't be indecisive here 4 5// Numeric literals 6type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6; 7 8// Useful for configurations with specific values 9interface RequestConfig { 10 method: 'GET' | 'POST'; 11 cache: 'no-cache' | 'reload' | 'force-cache'; 12 credentials: 'omit' | 'same-origin' | 'include'; 13}

This pattern is incredibly common in real code. Think of all the times you've had a value that could only be one of a limited set of options. Literal types make those restrictions explicit and verifiable.

Generics: TypeScript's Swiss Army Knife

Generics are probably the most intimidating part of TypeScript when you're starting out. You see code with <T> everywhere and think "what the heck is that?" But once it clicks, you realize they're incredibly useful.

The Problem Generics Solve

Imagine you need a function that returns the first element of an array. Without generics, you'd have to do this:

1function getFirstString(arr: string[]): string { 2 return arr[0]; 3} 4 5function getFirstNumber(arr: number[]): number { 6 return arr[0]; 7} 8 9function getFirstBoolean(arr: boolean[]): boolean { 10 return arr[0]; 11}

This is ridiculous. It's the same logic three times. And every time you need a new type, you'd have to write another function.

Generics solve this:

1function getFirst<T>(arr: T[]): T { 2 return arr[0]; 3} 4 5const firstString = getFirst(['a', 'b', 'c']); // TypeScript knows it's string 6const firstNumber = getFirst([1, 2, 3]); // TypeScript knows it's number 7const firstBool = getFirst([true, false]); // TypeScript knows it's boolean

One function, infinite types. The <T> is like a parameter, but for types instead of values. You're telling TypeScript: "I'm going to give you a type when I use this function, and I want you to use that type for both the parameter and the return value."

By convention, people use T (for "Type"), but you can use any name. In fact, more descriptive names sometimes help:

1function wrapInArray<Item>(item: Item): Item[] { 2 return [item]; 3} 4 5wrapInArray(42); // number[] 6wrapInArray('hello'); // string[]

Generics with Constraints: Not Everything Goes

Sometimes you want your generic to have certain properties. Not just any type, but types that meet certain requirements. For that, you use extends:

1// Only accepts types that have a 'length' property 2function logLength<T extends { length: number }>(item: T): void { 3 console.log(item.length); 4} 5 6logLength('hello'); // ✓ strings have length 7logLength([1, 2, 3]); // ✓ arrays have length 8logLength(42); // ❌ Error: numbers don't have length

This is super useful when you need to access specific properties inside the generic function.

Another common pattern is restricting to objects:

1function merge<T extends object, U extends object>(obj1: T, obj2: U): T & U { 2 return { ...obj1, ...obj2 }; 3} 4 5const merged = merge({ name: 'Ana' }, { age: 28 }); 6// TypeScript knows it has { name: string, age: number }

Generics in the Real World: APIs

Here's where generics really shine. When working with APIs, you tend to have repeating patterns:

1interface ApiResponse<T> { 2 data: T; 3 status: number; 4 message: string; 5 timestamp: Date; 6} 7 8interface User { 9 id: string; 10 name: string; 11 email: string; 12} 13 14interface Product { 15 id: string; 16 title: string; 17 price: number; 18} 19 20// One structure, multiple data types 21const userResponse: ApiResponse<User> = { 22 data: { 23 id: '1', 24 name: 'Ana', 25 email: 'ana@example.com' 26 }, 27 status: 200, 28 message: 'Success', 29 timestamp: new Date() 30}; 31 32const productsResponse: ApiResponse<Product[]> = { 33 data: [ 34 { id: '1', title: 'Laptop', price: 999 }, 35 { id: '2', title: 'Mouse', price: 29 } 36 ], 37 status: 200, 38 message: 'Success', 39 timestamp: new Date() 40};

This pattern appears constantly in production code. Instead of duplicating the response structure for each data type, you define it once with a generic and reuse it.

Other useful patterns:

1// Generic loading state 2interface LoadingState<T> { 3 data: T | null; 4 loading: boolean; 5 error: Error | null; 6} 7 8// Generic pagination 9interface Paginated<T> { 10 items: T[]; 11 total: number; 12 page: number; 13 pageSize: number; 14 hasMore: boolean; 15} 16 17type UserList = Paginated<User>; 18type ProductList = Paginated<Product>;

Default Values in Generics

If there's a type you use most of the time, you can set it as the default:

1interface Config<T = string> { 2 value: T; 3 label: string; 4} 5 6// No need to specify the type if it's string 7const config1: Config = { 8 value: 'hello', 9 label: 'Greeting' 10}; 11 12// But you can override it 13const config2: Config<number> = { 14 value: 42, 15 label: 'Answer' 16};

Function Typing: Beyond the Basics

Functions are the heart of JavaScript, and typing them correctly is key to getting the most out of TypeScript.

Optional Parameters: Controlled Flexibility

Sometimes a function can work with or without certain parameters:

1function greet(name: string, greeting?: string): string { 2 if (greeting) { 3 return `${greeting}, ${name}!`; 4 } 5 return `Hello, ${name}!`; 6} 7 8greet('Ana'); // "Hello, Ana!" 9greet('Ana', 'Good morning'); // "Good morning, Ana!"

The ? makes the parameter optional. But be careful: optional parameters must come after required ones. TypeScript won't let you cheat here.

1// ❌ This doesn't work 2function invalid(optional?: string, required: string) {} 3 4// ✓ This does 5function valid(required: string, optional?: string) {}

Another option is to use default values, which makes things more explicit:

1function createUser(name: string, role: string = 'user') { 2 return { name, role }; 3} 4 5createUser('Ana'); // { name: 'Ana', role: 'user' } 6createUser('Luis', 'admin'); // { name: 'Luis', role: 'admin' }

Rest Parameters: When You Don't Know How Many Arguments Will Come

1function sum(...numbers: number[]): number { 2 return numbers.reduce((acc, n) => acc + n, 0); 3} 4 5sum(1, 2, 3); // 6 6sum(1, 2, 3, 4, 5); // 15

This is especially useful for utility functions:

1function logWithPrefix(prefix: string, ...messages: string[]): void { 2 messages.forEach(msg => console.log(`${prefix}: ${msg}`)); 3} 4 5logWithPrefix('INFO', 'Server started', 'Listening on port 3000'); 6// INFO: Server started 7// INFO: Listening on port 3000

Function Types: Functions as First-Class Citizens

Functions can be values, and those values need types:

1type MathOperation = (a: number, b: number) => number; 2 3const add: MathOperation = (a, b) => a + b; 4const multiply: MathOperation = (a, b) => a * b; 5 6function calculate(a: number, b: number, operation: MathOperation): number { 7 return operation(a, b); 8} 9 10calculate(5, 3, add); // 8 11calculate(5, 3, multiply); // 15

This makes your code more flexible. Instead of having a rigid function that does only one thing, you have a function that accepts behavior as a parameter.

You can also define function types with interfaces:

1interface Validator { 2 (value: string): boolean; 3} 4 5const isEmail: Validator = (value) => { 6 return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); 7}; 8 9const isNotEmpty: Validator = (value) => { 10 return value.length > 0; 11};

Async Functions: Typed Promises

Async functions always return a Promise, and TypeScript needs to know what type of data is inside that promise:

1async function fetchUser(id: string): Promise<User> { 2 const response = await fetch(`https://example.com/users/${id}`); 3 4 if (!response.ok) { 5 throw new Error(`HTTP error! status: ${response.status}`); 6 } 7 8 const data = await response.json(); 9 return data; 10}

Notice the : Promise<User>. It's saying "this function returns a promise that will eventually contain a User."

If the async function doesn't return anything meaningful, use Promise<void>:

1async function saveUser(user: User): Promise<void> { 2 await fetch('https://example.com/users', { 3 method: 'POST', 4 headers: { 'Content-Type': 'application/json' }, 5 body: JSON.stringify(user) 6 }); 7 // Doesn't return anything 8}

Error handling can also be typed:

1async function getUserSafely(id: string): Promise<User | null> { 2 try { 3 const user = await fetchUser(id); 4 return user; 5 } catch (error) { 6 console.error('Failed to fetch user:', error); 7 return null; 8 } 9}

Here you're explicitly saying: "this function may return a User or null if something goes wrong."

Callbacks: Functions That Call Functions

Callbacks are functions you pass to other functions, and they need to be typed correctly:

1function processArray( 2 arr: number[], 3 callback: (item: number) => number 4): number[] { 5 return arr.map(callback); 6} 7 8const doubled = processArray([1, 2, 3], (n) => n * 2); 9// [2, 4, 6]

In more complex situations, like when working with APIs, callbacks become more sophisticated:

1interface FetchOptions { 2 onSuccess: (data: any) => void; 3 onError: (error: Error) => void; 4 onFinally?: () => void; 5} 6 7async function fetchData(url: string, options: FetchOptions): Promise<void> { 8 try { 9 const response = await fetch(url); 10 const data = await response.json(); 11 options.onSuccess(data); 12 } catch (error) { 13 options.onError(error as Error); 14 } finally { 15 options.onFinally?.(); 16 } 17} 18 19// Usage 20fetchData('https://example.com/users', { 21 onSuccess: (data) => console.log('Got data:', data), 22 onError: (error) => console.error('Oops:', error), 23 onFinally: () => console.log('Done!') 24});

Configuration: The Foundation of Everything

A good TypeScript configuration is like having a solid foundation for a house. It's not glamorous, but it makes all the difference. The tsconfig.json file controls how TypeScript compiles your code. Here's a solid starter configuration:

1{ 2 "compilerOptions": { 3 "target": "ES2020", 4 "module": "ESNext", 5 "moduleResolution": "node", 6 7 "outDir": "./dist", 8 "rootDir": "./src", 9 10 "lib": ["ES2020"], 11 12 "strict": true, 13 14 "esModuleInterop": true, 15 "allowSyntheticDefaultImports": true, 16 17 "skipLibCheck": true, 18 "forceConsistentCasingInFileNames": true 19 }, 20 "include": ["src/**/*"], 21 "exclude": ["node_modules", "dist"] 22}

This may look like a lot, but every option has a purpose.

The Most Important Option: strict

1{ 2 "compilerOptions": { 3 "strict": true 4 } 5}

This single line enables all of TypeScript's strict checks. It's tempting to turn it off when you're starting out because it makes the compiler complain about everything. But resist the temptation. strict: true is what makes TypeScript really protect you from bugs.

When enabled, TypeScript forces you to:

  • Not use implicit any
  • Handle null and undefined cases explicitly
  • Type all function parameters
  • And more...
1// With strict: true 2function greet(name: string | null): string { 3 if (name === null) { 4 return 'Hello, stranger!'; 5 } 6 return `Hello, ${name}!`; 7} 8 9// Without strict, you could do this and TypeScript wouldn't complain: 10function greetUnsafe(name) { // Implicitly 'any' 11 return `Hello, ${name.toUpperCase()}!`; // Could crash if name is null 12}

Additional Checking Options

Beyond strict, there are options that catch subtle problems:

1{ 2 "compilerOptions": { 3 "noUnusedLocals": true, 4 "noUnusedParameters": true, 5 "noImplicitReturns": true, 6 "noFallthroughCasesInSwitch": true 7 } 8}

noUnusedLocals: Warns you when you declare variables you never use.

1function calculate(a: number, b: number): number { 2 const temp = a + b; // ⚠️ Warning: never used 3 return a * b; 4}

noImplicitReturns: Ensures all branches of a function return a value.

1function getDiscount(price: number): number { 2 if (price > 100) { 3 return price * 0.1; 4 } 5 // ❌ Error: Not all branches return a value 6}

Configuration for Different Environments

Depending on where your code runs, you'll need different configurations:

For Node.js:

1{ 2 "compilerOptions": { 3 "target": "ES2020", 4 "module": "CommonJS", 5 "lib": ["ES2020"], 6 "types": ["node"] 7 } 8}

For browsers:

1{ 2 "compilerOptions": { 3 "target": "ES2020", 4 "module": "ESNext", 5 "lib": ["ES2020", "DOM"] 6 } 7}

The key difference is in lib, which defines what APIs are available. If you include "DOM", TypeScript knows about things like document and window. If you include "node", it knows about process and Buffer.

In conclusion, TypeScript can seem like a lot at first. Generics are confusing. Compound types feel unnecessary. But here's the truth: each of these features exists because it solves a real problem you'll encounter in production code. Generics prevent duplication. Union types make restrictions explicit. Strict configuration prevents subtle bugs.

Over time, writing TypeScript will feel as natural as writing JavaScript, but with a giant safety net. Every error the compiler catches is a bug that didn't make it to production. Every type you define is living documentation that never gets outdated. TypeScript isn't perfect. Sometimes it's verbose. Sometimes it makes you jump through unnecessary hoops. But on balance, it makes code safer, more maintainable, and easier to understand. And that's worth every <T> you write.