Guide · TypeScript Advanced Patterns

MCP server conditional types

Conditional types are TypeScript's way of expressing "if the input type matches this shape, the output type is X; otherwise it's Y." In MCP servers they unlock a layer of type safety that neither generics nor discriminated unions alone can provide: inferring the correct handler argument type directly from the Zod schema definition, enforcing tool contract invariants (read-only tools may not have side effects), and building tool factories that produce tools with precisely typed handlers without any manual type annotation at the call site. This guide covers practical conditional type patterns that improve correctness in medium-to-large MCP server codebases.

TL;DR

The core utility type is z.infer<TSchema>, which TypeScript resolves via conditional types under the hood. Build on this to create ToolHandler<TSchema> — a type that maps a Zod object schema to a correctly typed async handler function. Use MaybePromise<T> to accept both sync and async handlers transparently. Use mapped + conditional types to derive paginated response shapes from model types without repeating the pagination wrapper on every tool. Use the infer keyword to extract sub-shapes from nested Zod schemas automatically.

Inferring handler type from schema

The most common use of conditional types in MCP servers is ensuring the handler function matches the schema without redundant manual typing:

import { z } from 'zod';

// Core utility: extract the input type of a Zod schema
type SchemaInput<T extends z.ZodType> = z.infer<T>;

// Typed handler: given a schema, produce the correct async handler signature
type ToolHandler<TSchema extends z.ZodType> =
  (args: z.infer<TSchema>) => Promise<McpToolResult>;

type McpToolResult = {
  content: Array<{ type: 'text'; text: string }>;
  isError?: boolean;
};

// Helper to register a tool with inferred arg types — no manual annotation needed
function registerTool<TSchema extends z.ZodRawShape>(
  server: McpServer,
  name: string,
  description: string,
  schema: TSchema,
  handler: ToolHandler<z.ZodObject<TSchema>>
): void {
  server.tool(name, description, schema, handler);
}

// Usage — handler args are inferred from the schema object, no type annotation needed:
registerTool(server, 'get_user', 'Get a user by ID', {
  user_id: z.string().uuid(),
  include_profile: z.boolean().default(false),
}, async (args) => {
  // args: { user_id: string; include_profile: boolean } — inferred, not annotated
  const user = await db.users.findById(args.user_id);
  return { content: [{ type: 'text', text: user ? user.name : 'Not found' }] };
});

MaybePromise for sync/async transparency

Some tool handlers are synchronous — formatting, math, parsing — and forcing them into async adds noise. A MaybePromise<T> conditional type accepts both:

type MaybePromise<T> = T | Promise<T>;

type FlexibleToolHandler<TSchema extends z.ZodType> =
  (args: z.infer<TSchema>) => MaybePromise<McpToolResult>;

// Both are valid:
const syncHandler: FlexibleToolHandler<typeof CountSchema> = ({ value }) => ({
  content: [{ type: 'text', text: String(value * 2) }]
});

const asyncHandler: FlexibleToolHandler<typeof UserSchema> = async ({ user_id }) => {
  const user = await db.users.findById(user_id);
  return { content: [{ type: 'text', text: user?.name ?? 'Not found' }] };
};

// Normalize at call time:
async function callHandler<T extends z.ZodType>(
  handler: FlexibleToolHandler<T>,
  args: z.infer<T>
): Promise<McpToolResult> {
  return await handler(args);
}

Deriving paginated response types

A common pattern is returning paginated lists from tools. Conditional types let you derive the paginated response shape from the item type without repeating the pagination wrapper per-entity:

// Generic pagination wrapper
type PaginatedResult<T> = {
  items:       T[];
  total:       number;
  page:        number;
  per_page:    number;
  has_more:    boolean;
};

// Conditional type: if the schema has an 'items' field, it's paginated
type IsPaginated<T> = T extends { items: unknown[] } ? true : false;

// Serialize paginated or single results to MCP content text
function serializeResult<T>(
  result: T,
  formatter: (item: T extends PaginatedResult<infer I> ? I : T) => string
): McpToolResult {
  if (result && typeof result === 'object' && 'items' in result && Array.isArray((result as any).items)) {
    const paginated = result as PaginatedResult<unknown>;
    const lines = (paginated.items as any[]).map(formatter as (item: any) => string);
    return {
      content: [{
        type: 'text',
        text: lines.join('\n') + `\n\n(${paginated.total} total, page ${paginated.page}/${Math.ceil(paginated.total / paginated.per_page)})`
      }]
    };
  }
  return { content: [{ type: 'text', text: (formatter as any)(result) }] };
}

// Usage:
server.tool('list_users', 'List users with pagination', {
  page:     z.number().int().min(1).default(1),
  per_page: z.number().int().min(1).max(100).default(20),
  org_id:   z.string().uuid().optional(),
}, async (args) => {
  const result = await db.users.findPaginated(args);
  return serializeResult(result, (u: User) => `${u.id} ${u.name} <${u.email}>`);
});

Conditional types for tool middleware

Middleware wraps tool handlers with cross-cutting concerns (auth, logging, rate limiting). Conditional types ensure the middleware preserves the handler's type signature:

type WrappedHandler<TSchema extends z.ZodType, TCtx> =
  (args: z.infer<TSchema>, ctx: TCtx) => Promise<McpToolResult>;

type UnwrappedHandler<TSchema extends z.ZodType> =
  (args: z.infer<TSchema>) => Promise<McpToolResult>;

// Middleware that adds auth context — preserves schema typing
function withAuth<TSchema extends z.ZodType>(
  handler: WrappedHandler<TSchema, { userId: string; scopes: string[] }>,
  requiredScope: string
): UnwrappedHandler<TSchema> {
  return async (args) => {
    const ctx = await resolveAuthContext();
    if (!ctx.scopes.includes(requiredScope)) {
      return { isError: true, content: [{ type: 'text', text: `Required scope: ${requiredScope}` }] };
    }
    return handler(args, ctx);
  };
}

// Middleware that adds logging — preserves both input and output types
function withLogging<TSchema extends z.ZodType>(
  name: string,
  handler: UnwrappedHandler<TSchema>
): UnwrappedHandler<TSchema> {
  return async (args) => {
    const start = performance.now();
    try {
      const result = await handler(args);
      log.info({ tool: name, durationMs: performance.now() - start, isError: result.isError ?? false });
      return result;
    } catch (e) {
      log.error({ tool: name, durationMs: performance.now() - start, error: e });
      throw e;
    }
  };
}

// Compose — TypeScript infers the full type throughout the chain:
const getProjectHandler = withLogging('get_project',
  withAuth(async ({ project_id }, ctx) => {
    // ctx is { userId: string; scopes: string[] } — inferred from withAuth's TCtx
    const project = await db.projects.findById(project_id);
    return { content: [{ type: 'text', text: project?.name ?? 'Not found' }] };
  }, 'projects:read')
);

Infer keyword for nested schema extraction

The infer keyword inside conditional types extracts sub-types from complex Zod schemas — useful when you need to work with a specific field's type at the type level:

// Extract the element type of a Zod array schema
type ZodArrayElement<T extends z.ZodArray<z.ZodType>> =
  T extends z.ZodArray<infer Element> ? z.infer<Element> : never;

// Extract the key type of a Zod object schema's fields
type ZodObjectKeys<T extends z.ZodObject<z.ZodRawShape>> =
  T extends z.ZodObject<infer Shape> ? keyof Shape : never;

// Extract the inner type from optional/nullable wrappers
type UnwrapOptional<T extends z.ZodType> =
  T extends z.ZodOptional<infer Inner> ? z.infer<Inner> :
  T extends z.ZodNullable<infer Inner> ? z.infer<Inner> :
  z.infer<T>;

// Practical use: build a sort schema from a model's field names
const UserFields = z.object({
  name:       z.string(),
  email:      z.string(),
  created_at: z.string(),
});

type UserField = ZodObjectKeys<typeof UserFields>; // 'name' | 'email' | 'created_at'

const SortSchema = z.object({
  field: z.enum(Object.keys(UserFields.shape) as [UserField, ...UserField[]]),
  direction: z.enum(['asc', 'desc']).default('asc'),
});

// SortSchema stays in sync with UserFields automatically — add a field to UserFields
// and the sort enum picks it up without manual updates.

Compile-time invariant enforcement

Conditional types can encode invariants about your tools that must hold across the entire codebase. A common one: tools marked readOnly must not write to the database.

type ReadOnlyHandler<TSchema extends z.ZodType> = {
  readonly: true;
  handler: (args: z.infer<TSchema>) => Promise<McpToolResult>;
};

type MutatingHandler<TSchema extends z.ZodType> = {
  readonly: false;
  handler: (args: z.infer<TSchema>) => Promise<McpToolResult>;
};

type ToolDefinition<TSchema extends z.ZodType> =
  ReadOnlyHandler<TSchema> | MutatingHandler<TSchema>;

// Function that only accepts read-only tools for a read-only API tier
function registerReadOnlyTool<TSchema extends z.ZodType>(
  server: McpServer,
  name: string,
  description: string,
  schema: TSchema,
  definition: ReadOnlyHandler<TSchema>  // compile error if you pass MutatingHandler
): void {
  server.tool(name, { ...description, readOnlyHint: true }, schema, definition.handler);
}

Monitoring type-safe MCP servers

Conditional types catch entire categories of bugs at compile time — wrong arg types, missing handler branches, incorrect middleware composition. But compile-time safety doesn't extend to runtime behavior: a handler that typechecks can still fail because its database is unreachable, its upstream API is down, or a dependency changed its response shape.

AliveMCP probes your MCP server every 60 seconds at the protocol level, calling actual tools with valid inputs and checking for isError: true results that signal runtime handler failures invisible to TypeScript and transport health checks.

Further reading