Guide · TypeScript
MCP server template literal types
MCP tool names are plain strings at the protocol layer, but in TypeScript you can encode naming conventions as template literal types so the compiler rejects names that violate the convention. A tool registry typed with `${Namespace}_${Action}` literal types cannot accidentally accept "readFile" when the convention is "files_read" — the mismatch is a compile error, not a runtime bug discovered when an agent calls the wrong name.
TL;DR
Define type ToolName = `${Namespace}_${Action}` from union types Namespace and Action. Use Extract<> to pick specific tools by pattern. Use template literals in conditional types to extract the namespace prefix from any tool name — T extends `${infer NS}_${string}` ? NS : never. Combine with Zod .regex() for a runtime guard that mirrors the compile-time constraint. The result is a single source of truth: add a namespace or action to the union and every derived type, handler map, and log event type updates automatically.
The naming convention problem
LLMs and agent frameworks select MCP tools by name. An inconsistently named tool set creates several failure modes:
- Model confusion. A model trained on
namespace_actionconventions will mis-select a tool namedreadFilewhen it expectsfiles_read. The failure is silent — the model picks the closest match and proceeds with wrong semantics. - Dispatch bugs. A handler map keyed on tool names breaks silently when a tool is registered as
filesReadbut dispatched by the router asfiles_read. JavaScript's object property lookup returnsundefinedrather than an error, and the server returns a confusing "unknown tool" response at runtime. - Log correlation failures. Monitoring dashboards (including AliveMCP's uptime probes) correlate tool invocations with error events by name. Mixed conventions produce cardinality explosions and missed correlations.
- Breaking changes invisible to the compiler. Renaming a tool from
db_querytodatabase_queryis a string change. Without template literal types the compiler has no way to flag all the places that reference the old name.
Template literal types move all of these failures from runtime to compile time. The convention is encoded in the type system; violations are red squiggles in the editor before any code is executed.
Template literal type basics
TypeScript's template literal types use the same backtick syntax as string interpolation, but at the type level. Embedding a union type inside a template literal produces the cross-product of all combinations:
// tools/names.ts
type Namespace = 'files' | 'db' | 'http' | 'cache';
type Action = 'read' | 'write' | 'delete' | 'list' | 'ping';
// Produces every valid combination:
// 'files_read' | 'files_write' | 'files_delete' | 'files_list' | 'files_ping'
// 'db_read' | 'db_write' | 'db_delete' | 'db_list' | 'db_ping'
// ... (20 members total)
type ToolName = `${Namespace}_${Action}`;
// The compiler now rejects any string that is not in the union:
const valid: ToolName = 'files_read'; // OK
const camel: ToolName = 'filesRead'; // Error: Type '"filesRead"' is not assignable
const missing: ToolName = 'db_query'; // Error: 'query' is not in Action
The union has 20 members (4 namespaces × 5 actions). Most of those combinations do not correspond to real tools — you do not need a files_ping tool. That is fine: the type constrains the format, not the exhaustive list. The actual tool definitions form a subset of ToolName.
import { z } from 'zod';
interface ToolDef<N extends ToolName> {
name: N;
description: string;
inputSchema: z.ZodTypeAny;
}
// Name must satisfy the template — 'files_read' is valid, 'readFile' is not
const filesReadTool: ToolDef<'files_read'> = {
name: 'files_read',
description: 'Read a file from the workspace',
inputSchema: z.object({ path: z.string() }),
};
Deriving namespace and action unions from a const object
Defining Namespace and Action separately from the actual tools creates a drift risk: you add a namespace to the union but forget to register a tool, or remove a tool but leave its namespace in the union. A better approach derives the unions from the const tool definitions object, so the unions are always exactly what is registered:
// tools/registry.ts
const TOOLS = {
files_read: { description: 'Read a file', inputSchema: z.object({ path: z.string() }) },
files_write: { description: 'Write a file', inputSchema: z.object({ path: z.string(), content: z.string() }) },
files_delete: { description: 'Delete a file', inputSchema: z.object({ path: z.string() }) },
db_read: { description: 'Query the database', inputSchema: z.object({ sql: z.string() }) },
db_write: { description: 'Insert or update rows',inputSchema: z.object({ sql: z.string() }) },
http_ping: { description: 'Ping an HTTP endpoint',inputSchema: z.object({ url: z.string().url() }) },
cache_read: { description: 'Read from cache', inputSchema: z.object({ key: z.string() }) },
cache_write: { description: 'Write to cache', inputSchema: z.object({ key: z.string(), value: z.unknown() }) },
} as const;
// Derive the ToolName union from the const object — single source of truth
type ToolName = keyof typeof TOOLS;
// 'files_read' | 'files_write' | 'files_delete' | 'db_read' | 'db_write'
// | 'http_ping' | 'cache_read' | 'cache_write'
// Derive namespace and action unions by splitting on '_'
type ExtractNamespace<T extends string> = T extends `${infer NS}_${string}` ? NS : never;
type ExtractAction<T extends string> = T extends `${string}_${infer A}` ? A : never;
type Namespace = ExtractNamespace<ToolName>; // 'files' | 'db' | 'http' | 'cache'
type Action = ExtractAction<ToolName>; // 'read' | 'write' | 'delete' | 'ping'
Now the constraint flows in the other direction: the const object is the source of truth, and the type system derives the constraints from it rather than the other way around. Adding a new entry to TOOLS automatically expands ToolName, Namespace, and Action.
ExtractNamespace<T> conditional type
The conditional type T extends `${infer NS}_${string}` ? NS : never uses TypeScript's infer keyword inside a template literal to capture the prefix before the first underscore. This is more powerful than a simple string split because it operates at the type level and distributes over unions:
type ExtractNamespace<T extends string> =
T extends `${infer NS}_${string}` ? NS : never;
// Distributes over the union:
type NS = ExtractNamespace<'files_read' | 'db_write' | 'cache_read'>;
// Result: 'files' | 'db' | 'cache'
// Pick all tools belonging to a namespace:
type FilesTools = Extract<ToolName, `files_${string}`>;
// 'files_read' | 'files_write' | 'files_delete'
// Pick all tools for a specific action:
type ReadTools = Extract<ToolName, `${string}_read`>;
// 'files_read' | 'db_read' | 'cache_read'
// Combine both — specific namespace AND action:
type DbReadTool = Extract<ToolName, `db_${'read' | 'write'}`>;
// 'db_read' | 'db_write'
These derived types are immediately useful for building typed sub-routers. A files namespace handler only accepts FilesTools; passing it a 'db_read' name is a compile error.
type FilesHandler = {
[K in Extract<ToolName, `files_${string}`>]: (args: unknown) => Promise<unknown>;
};
const filesHandlers: FilesHandler = {
files_read: async (args) => { /* ... */ },
files_write: async (args) => { /* ... */ },
files_delete: async (args) => { /* ... */ },
// db_read: async () => {} — Error: Object literal may only specify known properties
};
Resource URI template types
MCP resource URIs have the same problem as tool names: they are plain strings at the protocol layer but carry implicit structure. A files:// resource is semantically different from a db:// resource. Template literal types encode this:
// resources/uris.ts
type Protocol = 'files' | 'db' | 'http' | 'cache';
// All valid resource URIs must start with a registered protocol
type ResourceURI = `${Protocol}://${string}`;
// These are valid:
const fileURI: ResourceURI = 'files:///workspace/src/index.ts'; // OK
const dbURI: ResourceURI = 'db://main/users/42'; // OK
// These are not:
const invalid: ResourceURI = 'ftp://host/file'; // Error: 'ftp' not in Protocol
const noSlash: ResourceURI = 'files:/workspace'; // Error: must have ://
// Extract protocol from a URI:
type URIProtocol<U extends string> =
U extends `${infer P}://${string}` ? P : never;
type P = URIProtocol<'db://main/users'>; // 'db'
// Route a URI to the correct handler by protocol:
type URIHandler = {
[P in Protocol]: (uri: `${P}://${string}`) => Promise<string>;
};
Pairing resource URI template types with tool name template types means both the action (files_read) and the target (files://…) must satisfy the same namespace constraint. A files_read tool that receives a db:// URI is a type error.
Structured logging event names as template literals
Structured log event names follow a hierarchy: tool.{toolName}.{outcome}. Template literal types enforce this hierarchy and make log event names refactor-safe — if a tool is renamed, all log event types that include it update automatically:
// logging/events.ts
type Outcome = 'start' | 'success' | 'error' | 'timeout';
// Event names follow the pattern tool.{toolName}.{outcome}
type LogEvent = `tool.${ToolName}.${Outcome}`;
// 'tool.files_read.start' | 'tool.files_read.success' | ...
// (32 members: 8 tools × 4 outcomes)
interface LogEntry {
event: LogEvent;
tool: ToolName;
outcome: Outcome;
duration_ms: number;
error?: string;
}
function logToolEvent(entry: LogEntry): void {
process.stdout.write(JSON.stringify({ ...entry, ts: Date.now() }) + '\n');
}
// Usage — all three fields must be consistent or the compiler complains:
logToolEvent({
event: 'tool.files_read.success',
tool: 'files_read',
outcome: 'success',
duration_ms: 42,
});
// This is a compile error — event and tool disagree:
logToolEvent({
event: 'tool.db_read.success', // 'db_read' in event
tool: 'files_read', // but 'files_read' in tool field
outcome: 'success',
duration_ms: 10,
});
AliveMCP parses structured log streams to detect tool-level error spikes. Consistent event name patterns — enforced at compile time — are what make that correlation reliable across server versions.
Runtime enforcement: Zod .regex() as runtime guard
Template literal types disappear at runtime. An MCP server receives tool names from the network as raw strings. The compile-time constraint must be mirrored by a runtime guard at the server boundary:
// validation/tool-name.ts
import { z } from 'zod';
// The regex mirrors the template literal type pattern
const NAMESPACES = ['files', 'db', 'http', 'cache'] as const;
const ACTIONS = ['read', 'write', 'delete', 'list', 'ping'] as const;
const namespacePattern = NAMESPACES.join('|');
const actionPattern = ACTIONS.join('|');
// Matches 'files_read', 'db_write', etc.
const toolNameRegex = new RegExp(`^(${namespacePattern})_(${actionPattern})$`);
export const ToolNameSchema = z.string().regex(toolNameRegex, {
message: `Tool name must match namespace_action pattern. ` +
`Namespaces: ${NAMESPACES.join(', ')}. Actions: ${ACTIONS.join(', ')}.`,
});
// In the MCP server request handler:
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const nameResult = ToolNameSchema.safeParse(request.params.name);
if (!nameResult.success) {
return {
isError: true,
content: [{ type: 'text', text: nameResult.error.issues[0].message }],
};
}
// nameResult.data is now a validated string — cast to ToolName is safe
const toolName = nameResult.data as ToolName;
return dispatch(toolName, request.params.arguments);
});
The regex is derived from the same NAMESPACES and ACTIONS const arrays that feed the TypeScript type unions, so they stay in sync automatically. Update the arrays in one place and both the compile-time type and the runtime validator update together.
Comparison: string vs literal union vs template literal
| Approach | What it catches | What it misses | Runtime cost |
|---|---|---|---|
string |
Nothing — any string is valid | Misspellings, wrong format, unknown tools | None |
Literal union 'files_read' | 'db_write' | … |
Unknown tool names, misspellings | Format violations (e.g. 'filesread' if that name is not listed) |
None |
Template literal `${Namespace}_${Action}` |
Format violations, unknown namespaces, unknown actions, cross-product constraints | Names that satisfy the format but are not registered (e.g. 'files_ping') |
None (type erasure) |
Template literal + Zod .regex() |
All of the above, plus network-boundary inputs | Nothing — compile time + runtime | Regex match per request (~microseconds) |
Literal union + Zod z.enum() |
Unknown tool names, compile time + runtime | Format violations not in the enum (fails silently until a name is added) | Set lookup per request (~nanoseconds) |
The practical recommendation: use template literal types for compile-time safety during development, and pair them with Zod .regex() at the network boundary. If you need a closed set of exactly the registered names (not all format-valid names), add a Zod z.enum() on top of the regex — z.enum(Object.keys(TOOLS) as [ToolName, ...ToolName[]]) — so the validator rejects names that satisfy the format but are not actually registered.
Related questions
Do template literal types affect runtime performance?
No. TypeScript's type system is erased at compilation — template literal types produce no runtime code. The only runtime cost is from any explicit validation you add (e.g., a Zod .regex() call). The regex itself is compiled once when the module loads and reused across requests, so the per-request cost is a single regex match, typically under a microsecond.
Can I use template literal types with z.enum()?
Yes, but they serve different purposes. z.enum() validates against a closed list of specific strings; template literal types validate format. You can combine both: first validate format with z.string().regex(toolNameRegex), then validate against the registered set with z.enum(Object.keys(TOOLS) as [ToolName, ...ToolName[]]). Zod supports chaining with .and() or you can compose them with z.intersection(). At the type level, the type of the resulting schema is the intersection of both constraints.
How do I evolve naming conventions without breaking all types?
Add a migration path type alias. If you are moving from camelCase to namespace_action, define type LegacyToolName = string temporarily alongside the new ToolName, and mark all handlers that accept LegacyToolName with a /** @deprecated */ JSDoc comment. TypeScript will surface deprecation warnings in the editor. Set a deadline in your changelog and remove the legacy alias in the next major version. The compiler will then flag every remaining callsite that has not been migrated.
Further reading
- MCP server utility types — custom TypeScript helpers for MCP
- MCP server mapped types — deriving handler maps from tool definitions
- MCP server tool design — naming, schemas, and documentation
- MCP server TypeScript — end-to-end type safety with the MCP SDK
- MCP server structured logging — event schemas and log correlation
- AliveMCP — MCP server health monitoring