TypeScript: Beyond any
Table of Contents
Open Table of contents
Mastering advanced typing for high-impact projects
In today’s development ecosystem, using TypeScript as if it were JavaScript on steroids is a waste. Many of us stay on the surface, but the true power appears when we stop fighting the compiler and start using it as our head architect.
Here is a deep dive into the techniques that separate “copy-paste” developers from senior software engineers.
1. The power of unknown and type narrowing
The any type is the “panic button”: it turns off all compiler alarms. On the contrary, unknown is the honest way of saying “I don’t know what this is yet.”
Why use it? Because it forces you to perform safety checks. It is ideal for API responses, dynamic form inputs, or integrations with external libraries of dubious origin.
async function fetchConfig(id: string): Promise<unknown> {
const response = await fetch(`/api/config/${id}`);
return response.json();
}
const config = await fetchConfig("user-123");
// config.theme; // Error: Object is of type 'unknown'
if (config && typeof config === "object" && "theme" in config) {
// Here TS already knows it's an object with the theme property
console.log("Configuring theme:", config.theme);
}
2. Type Guards and Type Predicates: The safety filter
Sometimes interfaces look very much alike, or we handle complex type unions. A Type Predicate is a function that returns a boolean, but with a superpower: if it returns true, the compiler “learns” the type of the variable in the rest of the block.
Practical scenario: Differentiate between an expected API error and a critical system error.
interface ApiError { message: string; code: number }
interface SystemError { stack: string; fatal: boolean }
function isApiError(error: any): error is ApiError {
return (error as ApiError).code !== undefined;
}
try {
// ... business logic
} catch (err) {
if (isApiError(err)) {
alert(`API Error (${err.code}): ${err.message}`);
} else {
console.error("Critical system error", err);
}
}
3. Utility Types: DRY (Don’t Repeat Yourself) in typing
If you have a User interface and create another one called UserUpdate manually, you are doing double the work. TS already has tools to transform existing types.
Partial<T>: Useful forpatchfunctions or initial configuration.Omit<T, K>: Perfect for protecting sensitive data (e.g., removing thepasswordbefore sending the object to the frontend).Readonly<T>: Total immutability at the type level.
interface DBUser {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
createdAt: Date;
}
// We create a type for registration (without ID or creation date)
type UserRegistration = Omit<DBUser, 'id' | 'createdAt'>;
// We create a type for editing (everything optional except the ID)
type UserUpdate = Partial<UserRegistration> & Pick<DBUser, 'id'>;
4. Discriminated Unions: The golden pattern
This is perhaps the most important pattern in Redux, React states, or navigation logic. It consists of adding a common property (usually called type or kind) to several types so that TS can easily differentiate them.
type LoadingState = { status: 'loading' };
type SuccessState = { status: 'success', data: string[] };
type ErrorState = { status: 'error', error: Error };
type State = LoadingState | SuccessState | ErrorState;
function renderUI(state: State) {
switch (state.status) {
case 'loading': return "Loading...";
case 'success': return `Items: ${state.data.length}`;
case 'error': return `Error: ${state.error.message}`;
}
}
5. Generics: Flexibility without losing control
Generics allow you to write functions or components that work with multiple types, while maintaining integrity. They are the “parameters of types”.
interface ApiResponse<T> {
data: T;
timestamp: number;
}
async function getResource<T>(url: string): Promise<ApiResponse<T>> {
const res = await fetch(url);
const data = await res.json();
return { data, timestamp: Date.now() };
}
// When calling it, we define what we expect to receive
const products = await getResource<Product[]>("/products");
6. The satisfies operator (TS 4.9+)
This operator is a recent gem. It allows you to validate that an object matches a type, but retains the exact shape of the original values for finer autocompletion.
type Color = string | { r: number, g: number, b: number };
const palette = {
primary: "#4f46e5",
secondary: { r: 100, g: 100, b: 255 }
} satisfies Record<string, Color>;
// Thanks to satisfies, TS knows that primary is a string:
palette.primary.toUpperCase();
// And it knows that secondary is an object:
console.log(palette.secondary.r);
Conclusion
Mastering these concepts is not just about “showing off” cleaner code. It’s about safety. In large projects, a good type system acts as a constant unit test that runs while you write every line of code.
Don’t be afraid of the complexity of typing; be afraid of a runtime error that you could have avoided with a little prior engineering.
Which of these are you going to apply in your next PR?