Guide · TypeScript Advanced Patterns
MCP server branded types
Structural typing means TypeScript accepts a string where a UserId is expected — and a UserId where an OrgId is expected, as long as both are strings. In an MCP server with dozens of tools operating on users, organizations, projects, and resources, this creates a whole class of silent bugs: passing the wrong ID type compiles, runs, and returns a wrong-but-plausible result that the LLM uses confidently. Branded (nominal) types make these bugs compile-time errors. This guide shows how to apply them throughout an MCP server — from Zod schemas through tool handlers to internal service calls.
TL;DR
A branded type is a plain primitive with a phantom type tag: type UserId = string & { readonly _brand: 'UserId' }. TypeScript won't assign a raw string to it without an explicit cast, so the brand acts as a gate. Use Zod's .brand() method to produce branded types from tool schema validation — the LLM still passes a string, Zod validates it, and the output is a UserId the handler can forward to services with full type safety. Combine with input validation to colocate format rules (UUID regex, slug pattern) with the brand definition so every tool picks them up automatically.
The wrong-ID problem in MCP servers
Consider a user management MCP server where many tools accept identifiers:
// Three different tools, all accept string IDs
server.tool('get_user', '...', { user_id: z.string() }, async ({ user_id }) => {
return db.users.find(user_id); // string goes straight to DB
});
server.tool('get_user_projects', '...', { user_id: z.string() }, async ({ user_id }) => {
return db.projects.findByOwner(user_id); // same
});
server.tool('add_user_to_project', '...', {
user_id: z.string(),
project_id: z.string(),
}, async ({ user_id, project_id }) => {
// TypeScript can't stop you from passing project_id where user_id is expected
return db.memberships.create(project_id, user_id); // args swapped — no error
});
The swapped arguments in add_user_to_project compile, pass type checking, and produce a runtime error or silent data corruption. With hundreds of tools across a large MCP server, this pattern is endemic — and it only manifests in production when an LLM happens to call a tool with arguments in a particular order.
Defining branded types
A branded type uses a phantom property — a property that exists only in the type system, not at runtime — to make the type nominal:
// Brand utility — the phantom property is never_brand to prevent accidental assignment
declare const brand: unique symbol;
type Brand<T, TBrand extends string> = T & { readonly [brand]: TBrand };
// Domain ID brands
export type UserId = Brand<string, 'UserId'>;
export type OrgId = Brand<string, 'OrgId'>;
export type ProjectId = Brand<string, 'ProjectId'>;
export type ToolName = Brand<string, 'ToolName'>;
// Constructor functions — the only way to create a branded value
// (except for Zod parsing, covered below)
export function asUserId(s: string): UserId {
if (!/^usr_[0-9a-z]{24}$/.test(s)) throw new Error(`Invalid UserId: ${s}`);
return s as UserId;
}
export function asProjectId(s: string): ProjectId {
if (!/^prj_[0-9a-z]{24}$/.test(s)) throw new Error(`Invalid ProjectId: ${s}`);
return s as ProjectId;
}
Now TypeScript rejects a bare string where a UserId is expected, and rejects a UserId where a ProjectId is expected. The as cast is confined to constructor functions — the rest of your codebase is type-safe.
Zod branded schemas
Zod's .brand() method adds a phantom brand to the inferred type, so schema validation doubles as a constructor:
import { z } from 'zod';
// Define the schema once — format validation + branding in one step
export const UserIdSchema = z
.string()
.regex(/^usr_[0-9a-z]{24}$/, 'Invalid user ID format (expected usr_...)')
.brand<'UserId'>();
export const ProjectIdSchema = z
.string()
.regex(/^prj_[0-9a-z]{24}$/, 'Invalid project ID format (expected prj_...)')
.brand<'ProjectId'>();
export const OrgIdSchema = z
.string()
.regex(/^org_[0-9a-z]{24}$/, 'Invalid org ID format (expected org_...)')
.brand<'OrgId'>();
// Inferred types are branded automatically
type UserId = z.infer<typeof UserIdSchema>; // string & z.BRAND<'UserId'>
type ProjectId = z.infer<typeof ProjectIdSchema>;
// Use directly in tool schemas:
server.tool(
'add_user_to_project',
'Add a user as a member of a project',
{
user_id: UserIdSchema,
project_id: ProjectIdSchema,
},
async ({ user_id, project_id }) => {
// user_id is UserId, project_id is ProjectId — can't be swapped
await db.memberships.create(user_id, project_id); // correctly ordered
return { content: [{ type: 'text', text: `User ${user_id} added to project ${project_id}` }] };
}
);
Zod parses the raw string from the LLM, validates the format (wrong format → isError: true validation message), and returns the branded type. The tool handler receives a UserId, not a plain string.
Cross-tool type safety
Brands enforce correct ID threading across chained tool calls. When an LLM calls list_projects and passes a result ID to get_project_members, the internal service functions only accept correctly-branded types:
// Service layer uses branded types exclusively
interface UserService {
findById(id: UserId): Promise<User | null>;
listByOrg(orgId: OrgId): Promise<User[]>;
}
interface ProjectService {
findById(id: ProjectId): Promise<Project | null>;
addMember(projectId: ProjectId, userId: UserId): Promise<void>;
}
// This won't compile:
// projectService.addMember(userId, projectId) // Error: Argument of type 'UserId' is not
// // assignable to parameter of type 'ProjectId'
// Tool: list_projects returns ProjectId values
server.tool('list_projects', 'List projects for a user', {
user_id: UserIdSchema,
}, async ({ user_id }) => {
const projects = await projectService.listByOwner(user_id);
// projects[i].id is ProjectId — typed in the return value
return {
content: [{ type: 'text', text: projects.map(p => `${p.id}: ${p.name}`).join('\n') }]
};
});
// Tool: get_project_members requires ProjectId — Zod validates the format
server.tool('get_project_members', 'Get members of a project', {
project_id: ProjectIdSchema,
}, async ({ project_id }) => {
const members = await userService.listByProject(project_id);
return { content: [{ type: 'text', text: members.map(m => m.email).join('\n') }] };
});
JSON Schema output from branded Zod schemas
Branded types are a TypeScript concept — the LLM sees raw JSON Schema. Zod's .describe() and .meta() let you embed the brand meaning into the schema the LLM receives:
export const UserIdSchema = z
.string()
.regex(/^usr_[0-9a-z]{24}$/)
.brand<'UserId'>()
.describe('A user identifier in the format usr_<24-char alphanumeric ID>. ' +
'Obtain from list_users or get_current_user.');
export const ProjectIdSchema = z
.string()
.regex(/^prj_[0-9a-z]{24}$/)
.brand<'ProjectId'>()
.describe('A project identifier in the format prj_<24-char alphanumeric ID>. ' +
'Obtain from list_projects or create_project.');
The description tells the LLM which tool to call to get a valid ID, and what format to expect. This prevents the LLM from constructing IDs from memory or guessing — it knows to call the right tool first and thread the result to the tool that needs it.
Branded types for non-ID values
Brands apply beyond identifiers. Any value that carries semantic meaning beyond its primitive type benefits from branding:
// Sanitized strings — prevent XSS by tracking whether content was sanitized
type SanitizedHtml = Brand<string, 'SanitizedHtml'>;
type RawUserInput = Brand<string, 'RawUserInput'>;
function sanitize(raw: RawUserInput): SanitizedHtml {
return raw.replace(/[<>"']/g, c => `&#${c.charCodeAt(0)};`) as SanitizedHtml;
}
// Can't pass raw input to a function expecting sanitized content:
// renderHtml(userInput) // Error: RawUserInput not assignable to SanitizedHtml
// Validated paths — prevent path traversal bugs
type SafePath = Brand<string, 'SafePath'>;
export const SafePathSchema = z
.string()
.refine(p => !p.includes('..') && !p.startsWith('/'), 'Path must be relative and contain no ..')
.transform(p => p as SafePath)
.describe('A safe relative file path (no ../ or absolute paths)');
// Percent values — prevent unit confusion
type PercentValue = Brand<number, 'Percent'>; // 0–100
const PercentSchema = z
.number()
.min(0).max(100)
.brand<'Percent'>()
.describe('A percentage value between 0 and 100 (not 0–1)');
Branded types in tool output
Tool outputs are plain text for the LLM, but downstream tool calls chain through the context. When a tool returns an ID it should return it in a format that the next tool's schema will validate — making IDs structurally recognizable keeps the LLM from hallucinating new IDs:
server.tool('create_project', 'Create a new project', {
name: z.string().min(1).max(128),
owner_id: UserIdSchema,
}, async ({ name, owner_id }) => {
const project = await projectService.create({ name, ownerId: owner_id });
// Return the project ID in the expected format so the LLM can pass it
// directly to get_project_members, add_user_to_project, etc.
return {
content: [{
type: 'text',
text: `Project created.\nproject_id: ${project.id}\nname: ${project.name}\n` +
`Pass project_id to other project tools.`
}]
};
// project.id is ProjectId here — TypeScript confirms the format is correct
});
Formatting the output with a consistent field_name: value pattern helps the LLM extract and reuse the ID correctly in subsequent tool calls.
Monitoring and operational impact
Branded types reduce a class of runtime errors — wrong-ID bugs — to compile-time errors. But branded type checks are static: they don't prevent failures caused by IDs that are syntactically correct but reference deleted resources, or service-layer failures that surface as isError: true in tool calls. Format-valid IDs that hit a deleted user row or an unavailable database produce errors that only manifest when tools are called with live data.
AliveMCP probes your MCP server every 60 seconds using the full protocol handshake, detecting tool-level failures that don't appear in transport health checks — including database connectivity issues, expired tokens, and service-layer regressions that make specific tools return isError: true while initialize and tools/list remain healthy.
Further reading
- MCP server discriminated unions — polymorphic tool inputs and outputs
- MCP server generics — reusable tool builder patterns
- MCP server Zod validation — schema-first tool definitions
- MCP server input validation — defense at tool boundaries
- MCP server type safety — TypeScript patterns for safe tool handlers
- MCP server error handling — isError responses and recovery hints
- AliveMCP — uptime monitoring for HTTP-deployed MCP servers