Search

Back to all posts

TypeScript: Beyond `any`

Avatar
Emmanuel Luján
5 min read

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.

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?