DEV Community

Midas126
Midas126

Posted on

Beyond Any and Unknown: Mastering TypeScript's Advanced Type Safety

Why "Any" Isn't the Answer

We've all been there. You're integrating a third-party API, parsing dynamic JSON, or refactoring a legacy JavaScript file into TypeScript. The any type beckons like a siren song—a quick fix that silences the compiler and lets you move forward. But every time you use any, you're essentially turning off TypeScript's greatest superpower: static type checking. You're trading short-term convenience for long-term bugs and maintenance headaches.

This guide isn't about TypeScript basics. It's about moving beyond the crutch of any and unknown to leverage TypeScript's sophisticated type system for truly robust, self-documenting, and error-resistant code. We'll explore practical patterns and advanced types that make your code not just typed, but intelligently typed.

The unknown Type: A Safer Starting Point

Before we dive into advanced patterns, let's address the proper alternative to any: the unknown type. Introduced in TypeScript 3.0, unknown is the type-safe counterpart of any. While a value of type any can be assigned to anything and have any property accessed on it, a value of type unknown cannot.

// The Problem with `any`
let dangerous: any = "hello";
let num: number = dangerous; // No error, but runtime disaster awaits
dangerous.toFixed(); // Compiler is silent, runtime error: "toFixed is not a function"

// The Safety of `unknown`
let safe: unknown = JSON.parse('{"data": 123}');
// let num2: number = safe; // ERROR: Type 'unknown' is not assignable to type 'number'.
// safe.data; // ERROR: Object is of type 'unknown'.

// You must narrow the type first
if (typeof safe === 'object' && safe !== null && 'data' in safe) {
  // Now TypeScript knows the shape
  console.log((safe as { data: number }).data); // Works with assertion
}
Enter fullscreen mode Exit fullscreen mode

Rule of thumb: When dealing with truly dynamic data (API responses, user input, deserialized JSON), start with unknown, not any. It forces you to perform type narrowing, making your intentions explicit and your code safer.

Advanced Pattern 1: Discriminated Unions for State Management

One of the most powerful patterns for modeling application state is the discriminated union (or tagged union). It's perfect for representing finite states like API request status, modal visibility, or user authentication flow.

// Instead of error-prone boolean flags and nullable data:
type NaiveState = {
  isLoading: boolean;
  error: string | null;
  data: User | null;
};

// Use a discriminated union:
type ApiState<User> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error'; errorMessage: string };

// Usage becomes exhaustive and safe
function renderState(state: ApiState<User>) {
  switch (state.status) {
    case 'idle':
      return <div>Ready to fetch</div>;
    case 'loading':
      return <Spinner />;
    case 'success':
      return <Profile user={state.data} />; // TypeScript KNOWS data exists here
    case 'error':
      return <ErrorMessage msg={state.errorMessage} />; // TypeScript KNOWS errorMessage exists here
    // Forgot a case? TypeScript will warn you if switch is not exhaustive
  }
}
Enter fullscreen mode Exit fullscreen mode

This pattern eliminates an entire class of bugs where you might try to access data when isLoading is true, or show an error message when error is null.

Advanced Pattern 2: Template Literal Types for String Magic

Template literal types, introduced in TypeScript 4.1, allow you to create types based on string templates. They're incredibly useful for type-safe APIs, routing systems, and dynamic keys.

// Type-safe event handler names
type EventName = `on${Capitalize<'click' | 'change' | 'submit'>}`;
// Result: "onClick" | "onChange" | "onSubmit"

// Dynamic API endpoint builder
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type ApiEndpoint = `/api/${string}`;
type ApiRoute = `${HttpMethod} ${ApiEndpoint}`;

function makeRequest(route: ApiRoute, data?: unknown) {
  // Implementation
}

makeRequest('GET /api/users'); // Valid
makeRequest('POST /api/users'); // Valid
makeRequest('PATCH /api/users'); // ERROR: Type '"PATCH /api/users"' is not assignable to type 'ApiRoute'.
makeRequest('GET /users'); // ERROR: Doesn't match the `/api/${string}` pattern

// Even more powerful: inferring types from patterns
type ExtractIdFromRoute<T extends string> =
  T extends `/api/users/${infer Id}/posts` ? Id : never;

type UserId = ExtractIdFromRoute<'/api/users/abc123/posts'>; // Type is "abc123"
Enter fullscreen mode Exit fullscreen mode

Advanced Pattern 3: Conditional Types and Type Inference

Conditional types allow types to be selected based on conditions, much like a ternary operator for types. Combined with infer, they let you extract and manipulate type information programmatically.

// A practical example: Unwrapping Promise types
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

type StringPromise = Promise<string>;
type Unwrapped = UnwrapPromise<StringPromise>; // Type is `string`
type NotAPromise = UnwrapPromise<number>; // Type is `number`

// Building a type-safe function that extracts return type
function createLogger<T extends () => any>(fn: T) {
  return {
    executeAndLog: () => {
      const result = fn();
      console.log('Function returned:', result);
      return result;
    },
    // The return type is inferred from T
    expectedReturnType: null as unknown as ReturnType<T>,
  };
}

const logger = createLogger(() => 42);
// logger.expectedReturnType is typed as `number`
Enter fullscreen mode Exit fullscreen mode

Putting It All Together: A Type-Safe API Client

Let's build a practical example that combines these concepts into a type-safe API client.

// Define our resource types
type Resource = 'users' | 'posts' | 'comments';
type ResourceId = string;

// Template literal types for our endpoints
type Endpoint = `/${Resource}` | `/${Resource}/${ResourceId}`;

// Conditional type for response data based on endpoint
type ResponseForEndpoint<T extends Endpoint> =
  T extends `/users/${string}` ? User :
  T extends '/users' ? User[] :
  T extends `/posts/${string}` ? Post :
  T extends '/posts' ? Post[] :
  never;

// Our type-safe fetch wrapper
async function fetchResource<T extends Endpoint>(
  endpoint: T
): Promise<ResponseForEndpoint<T>> {
  const response = await fetch(`https://api.example.com${endpoint}`);
  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }
  // We trust our API contract, but in real life you'd validate this
  return response.json() as Promise<ResponseForEndpoint<T>>;
}

// Usage with full type safety
async function app() {
  const user = await fetchResource('/users/abc123'); // Type: User
  const allPosts = await fetchResource('/posts'); // Type: Post[]
  // const invalid = await fetchResource('/products'); // ERROR: not a valid Resource
}
Enter fullscreen mode Exit fullscreen mode

Your Type Safety Action Plan

  1. Ban any in new code: Configure your ESLint with @typescript-eslint/no-explicit-any to make any usage an error.
  2. Embrace unknown for boundaries: Use unknown for data entering your system (APIs, user input, file parsing).
  3. Model state with unions: Replace boolean flags with discriminated unions for finite state machines.
  4. Use the standard utility types: Familiarize yourself with TypeScript's built-ins like Partial<T>, Pick<T, K>, Omit<T, K>, and ReturnType<T>.
  5. Gradually introduce advanced types: Start with one pattern (like template literals for routes) and expand as you become comfortable.

TypeScript's true power isn't just adding types to JavaScript—it's about using the type system to encode your application's logic and constraints. When you move beyond any and master these advanced patterns, you create code that's not just type-safe, but logically sound. The compiler becomes a true partner, catching bugs before runtime and serving as living documentation for your system's design.

Challenge for this week: Find one place in your codebase where you're using any or several nullable flags, and refactor it using one of the patterns above. Share what you discover in the comments!


Want to dive deeper? The TypeScript Handbook is an excellent resource, and the Type Challenges repository offers puzzles to sharpen your type-level thinking.

Top comments (0)