TypeScript Fundamentals for Plugin Development
Master the TypeScript language features essential for robust plugin development.
Chapter 2: TypeScript Fundamentals for Plugin Development
In the previous chapter we defined the three core interfaces — PluginManifest, PluginModule, and PluginSDK — and talked about them as if they were straightforward to implement. They are, in concept. The hard part is making them safe at the edges: where your carefully typed host application meets a plugin that was written by someone you've never met, compiled separately, and loaded at runtime from a URL.
TypeScript's compile-time guarantees only cover code that was compiled together. The moment you call import() on an external module, you are, from TypeScript's perspective, holding an unknown. Everything the author of that plugin intended to export — the lifecycle hooks, the manifest, the components — arrives without any type information attached. It's your job to verify it.
This is the central challenge of plugin architecture from a type-safety perspective, and it's what this chapter is about. We'll look at the TypeScript patterns that production systems have developed to deal with it: generics for expressing contracts that work across frameworks, conditional types and template literals for event systems and hook signatures, branded types for preventing the quiet bugs that plain strings invite, and runtime validation for enforcing contracts at the boundary where compile-time safety runs out.
2.1 Generics — The Foundation of Flexible Contracts
The first tool in the plugin author's TypeScript kit is also the most important: generics. A plugin system needs to express contracts that are simultaneously strict and flexible — strict enough that a plugin author knows exactly what shape they need to provide, flexible enough that the system doesn't force a React widget definition onto a Vue developer.
Generics solve this by making the places where frameworks differ into type parameters. Consider how VS Code handles contribution points:
// Framework-agnostic base definition
interface Widget<TComponent = unknown> {
id: string;
title: string;
render: TComponent;
}
// VS Code's actual pattern for declarative contributions
interface ContributionPoint<T = unknown> {
when?: string; // Conditional activation
group?: string; // Organisation
[key: string]: T | string | undefined;
}
interface CommandContribution extends ContributionPoint {
command: string;
title: string;
category?: string;
enablement?: string; // Type-safe condition expressions
}The TComponent = unknown default is deliberate. At the core SDK level, the system does not know what a component is — it is whatever the consuming framework says it is. React plugins will substitute React.FC. Vue plugins will substitute a component options object. The contract holds across all of them because the part that differs is parameterised rather than hardcoded.
Backstage takes this further with constrained generics for its dependency injection system:
// Backstage's approach — constrained generic services
interface ApiFactory<Api, Impl extends Api, Deps = {}> {
api: ApiRef<Api>;
deps: { [name in keyof Deps]: ApiRef<Deps[name]> };
factory(deps: Deps): Impl;
}
// This prevents plugins from providing incompatible implementations
const myApi = createApiFactory({
api: catalogApiRef, // ApiRef<CatalogApi>
deps: { discovery: discoveryApiRef },
factory: ({ discovery }) => new MyCatalogApi(discovery), // ✅ Type-safe
});The Impl extends Api constraint is the key insight: a plugin can provide any implementation it likes, but it must satisfy the declared interface. The type system enforces the contract at the point of registration, long before any code runs.
Higher-order types extend this idea to plugin composition. If you want to express a complete plugin as a single type:
type CompletePlugin<TComponent = unknown> = PluginManifest & {
module: PluginModule<TComponent>;
};And for route definitions that need to carry a component type through:
interface PluginRouteDefinition<TComponent = unknown> {
path: string;
component: TComponent;
}The pattern is consistent: wherever framework-specific types would create hard coupling, replace the concrete type with a generic parameter and default it to unknown. Framework adapters then specialise that parameter for their specific context, and the core plugin system stays neutral.
2.2 Conditional Types and Template Literals
Generics handle the case where different callers need to substitute different types. Conditional types handle the case where the type itself should change based on something else — the phase of a build process, the kind of event being listened to, the framework in use.
Vite's hook system is the clearest example of this in production. Different hooks in a plugin fire at different phases of the build, and each phase has a different execution context available:
// Vite's hook system — conditional based on plugin phase
type HookHandler<T> = T extends 'build'
? (this: PluginContext, ...args: unknown[]) => void
: T extends 'transform'
? (this: TransformPluginContext, code: string, id: string) => TransformResult
: T extends 'generateBundle'
? (this: PluginContext, options: OutputOptions, bundle: OutputBundle) => void
: never;
// Framework adapter pattern from production systems
type PluginComponent<TFramework> = TFramework extends 'react'
? React.FC
: TFramework extends 'vue'
? Vue.Component
: TFramework extends 'angular'
? { template: string; component?: unknown }
: TFramework extends 'svelte'
? { component: unknown }
: unknown;The never at the end of the hook handler chain is intentional — it makes a misconfigured hook a compile-time error rather than a silent no-op at runtime.
Template literal types bring a similar precision to event systems. VS Code's activation events are a good example: they follow patterns like onLanguage:typescript or onCommand:extension.hello, and the colon-separated structure is load-bearing — the host uses it to determine when to wake up an extension. Without template literals, you'd either accept any string (too loose) or enumerate every possible event (impossible). Template literals give you the third option:
// VS Code's actual activation event pattern
type ActivationEvent =
| `onLanguage:${string}` // Language-specific activation
| `onCommand:${string}` // Command-based activation
| `onDebug:${string}` // Debug session activation
| `onTaskType:${string}` // Task type activation
| 'onStartupFinished' // Lifecycle activation
| '*'; // Always activate (discouraged)
type PluginEvent<TType extends string = string> = `plugin:${TType}:${string}`;Babel uses the same idea for its visitor pattern, where method names on the visitor object follow a predictable structure:
// Babel's visitor pattern with template literals
type VisitorKeys = 'Program' | 'FunctionDeclaration' | 'CallExpression' | 'Identifier';
type EnterExit<T> = `${T}${'Enter' | 'Exit' | ''}`;
type VisitorMethods = {
[K in EnterExit<VisitorKeys>]?: (path: NodePath) => void;
};This lets a plugin author write either FunctionDeclaration (called on entry) or FunctionDeclarationEnter and FunctionDeclarationExit (called explicitly on enter and exit), and the type system validates all three forms without manual enumeration.
Utility types round out the picture for API design. Partial<PluginManifest> lets you accept an incomplete manifest during drafting or validation stages. Record<string, PluginRouteDefinition> creates a route map where keys are path strings. Pick and Omit let you create focused subsets of interfaces for specific contexts — a ReadonlyManifest for the host's internal use, a PublicManifest for what gets exposed to plugins. These are not exotic features, but using them deliberately in SDK design prevents a lot of interface sprawl.
2.3 Type Safety at the Dynamic Boundary
Everything in the previous two sections applies to code you compile. The moment a plugin is loaded dynamically — fetched from a CDN, imported from the file system at runtime — you are working with unknown. The loaded module might perfectly match PluginModule. It might be missing mount. It might be an error page returned by a CDN. The type system cannot help you here, and that's the point where most plugin systems introduce runtime validation.
The pattern is straightforward: validate at the boundary, trust inside. When a plugin module arrives, run it through a validator before treating it as typed:
import { z } from 'zod';
const manifestSchema = z.object({
id: z.string(),
name: z.string(),
version: z.string(),
entry: z.string(),
});
function validateManifest(manifest: unknown): manifest is PluginManifest {
return manifestSchema.safeParse(manifest).success;
}The reason to use a library like Zod here rather than a hand-rolled check is that the schema serves double duty: it validates at runtime and it is the source of truth for the TypeScript type. If you add a field to PluginManifest, you add it to the schema, and both the runtime check and the compile-time type update together. They cannot drift apart, which is a common failure mode when validation logic is written by hand.
For the module itself — the object with init, mount, and unmount — a type guard is usually sufficient:
function isPluginModule(obj: unknown): obj is PluginModule {
return typeof (obj as PluginModule).init === 'function';
}Type guards like this are narrow by design. You are not checking the full shape of the object; you are checking enough to distinguish a valid plugin from something that is clearly not one. The full contract is enforced by the TypeScript types that apply once the guard passes.
Branded types address a subtler class of bugs that the above validation cannot catch: the problem of passing the right type in the wrong position. Consider a plugin system that has plugin IDs, command IDs, and configuration keys — all strings, all semantically distinct. Without branded types, registerCommand(pluginId, commandId) and registerCommand(commandId, pluginId) both type-check. With them, they don't:
// VS Code's approach to preventing string confusion
type ExtensionId = string & { readonly brand: 'ExtensionId' };
type CommandId = string & { readonly brand: 'CommandId' };
type ConfigurationKey = string & { readonly brand: 'ConfigurationKey' };
// Backstage uses similar patterns for service identification
type ServiceRef<T> = {
id: string & { readonly brand: 'ServiceRef' };
$$type: T; // Phantom type for service interface
};
// Helper functions ensure proper construction
function createExtensionId(raw: string): ExtensionId {
return raw as ExtensionId;
}
function createServiceRef<T>(options: { id: string }): ServiceRef<T> {
return {
id: options.id as ServiceRef<T>['id'],
$$type: undefined as any,
};
}
// NocoBase demonstrates collection-specific branded types
type TableName = string & { readonly brand: 'TableName' };
type FieldName = string & { readonly brand: 'FieldName' };
type PluginName = string & { readonly brand: 'PluginName' };
// This prevents mixing table names with field names
class Schema {
addField(table: TableName, field: FieldName) { /* ... */ }
}The brand is a phantom — it exists only in the type system, costs nothing at runtime, and has no representation in the emitted JavaScript. But it makes a large class of argument-order bugs impossible. Vite and Babel extend this idea to file paths and AST nodes respectively, where the same principle applies: types that are structurally identical need semantic separation to be used safely:
// Vite's approach to file paths with context
type ResolvedId = string & {
readonly brand: 'ResolvedId';
readonly meta?: { [key: string]: unknown };
};
// Babel's branded AST nodes prevent mixing different node types
type NodeType = 'Program' | 'FunctionDeclaration' | 'CallExpression';
type ASTNode<T extends NodeType> = {
type: T;
readonly brand: `ASTNode<${T}>`;
};Together — compile-time generics, runtime validation, and branded types — these patterns form a layered safety story. No single one is enough on its own. All three together give you a plugin system where the things that can be caught at compile time are, and the things that can only be caught at runtime are caught early, at the boundary, before they cause harder-to-diagnose failures deeper in the system.
2.4 Interface Design Patterns — Learning from Production Systems
Good TypeScript types are necessary but not sufficient. The shape of your interfaces — what they require, what they leave optional, how they compose — determines how pleasant it is to build plugins against your SDK. Production systems have converged on a few patterns worth understanding.
Stable Contracts with Room to Grow — The VS Code Model
VS Code's extension interface has remained remarkably stable across years of development. Part of that stability comes from a design that separates what the host requires (very little) from what it exposes (a lot):
// VS Code's actual extension interface
interface Extension<T = any> {
readonly id: string;
readonly extensionUri: Uri;
readonly extensionPath: string;
readonly isActive: boolean;
readonly packageJSON: any;
readonly extensionKind: ExtensionKind;
activate(): Thenable<T>;
exports: T;
}
// Contribution points are strongly typed
interface ContributionPoints {
commands?: CommandContribution[];
menus?: Record<string, MenuContribution[]>;
keybindings?: KeybindingContribution[];
languages?: LanguageContribution[];
grammars?: GrammarContribution[];
// ... extensible but controlled
}
// Each contribution type has specific requirements
interface CommandContribution {
command: string; // Must be unique
title: string | { value: string; original: string }; // i18n support
category?: string;
icon?: string | { light: string; dark: string };
enablement?: string; // When command is available
when?: string; // Context condition
}Notice that ContributionPoints uses optional fields throughout — new contribution types can be added without breaking existing extensions. And individual contribution types like CommandContribution carry only what the host genuinely needs; the extension is not required to fill in anything it hasn't chosen to support.
Abstractions Over Implementations — The Backstage Pattern
Backstage's backend plugin interface demonstrates what good dependency inversion looks like in practice. Rather than giving plugins access to specific database libraries or specific HTTP clients, the host provides abstract interfaces. Plugins depend on those abstractions, and the host resolves them to concrete implementations:
// Plugins depend on abstractions, not implementations
interface PluginEnvironment {
logger: Logger;
database: PluginDatabaseManager;
cache: PluginCacheManager;
config: Config;
reader: UrlReader;
discovery: PluginEndpointDiscovery;
tokenManager: TokenManager;
scheduler: PluginTaskScheduler;
}
interface PluginDatabaseManager {
getClient(): Promise<Knex>;
migrations?: {
skip?: boolean;
};
}
// The actual plugin interface inverts control
interface BackendPlugin {
start(env: PluginEnvironment): Promise<Router>;
stop?(): Promise<void>;
}The practical benefit is that the host can change its database driver, swap its logging library, or modify how the scheduler works — and existing plugins are unaffected, because they never touched those implementations directly.
Factory Functions — The Babel Approach
Babel's plugin interface is a factory function rather than a class or a plain object. This gives plugins access to the Babel API and their options at the moment they're created, without needing to hold references or manage injection themselves:
// Babel's plugin factory pattern
interface PluginAPI {
types: typeof t; // AST builder utilities
template: typeof template; // Template string parser
version: string; // Babel version
cache: {
get<T>(key: string): T | undefined;
set<T>(key: string, value: T): void;
forever(): { get<T>(key: string): T; set<T>(key: string, value: T): void };
};
}
// Plugin factory signature ensures consistency
type PluginFactory = (
api: PluginAPI,
options: PluginOptions,
dirname: string,
) => {
name?: string;
manipulateOptions?: (opts: PluginOptions, parserOpts: any) => void;
pre?: (this: PluginPass, file: BabelFile) => void;
visitor?: Visitor;
post?: (this: PluginPass, file: BabelFile) => void;
inherits?: any;
};
// Usage maintains type safety while allowing flexibility
const myPlugin: PluginFactory = (api, options) => ({
name: 'my-transform',
visitor: {
FunctionDeclaration(path) {
const newNode = api.types.variableDeclarator(
api.types.identifier('transformed'),
path.node.id
);
},
},
});The factory pattern also makes caching straightforward: Babel calls the factory once and caches the result, which is why the cache helper is available in PluginAPI. Plugin authors can opt into this by using api.cache.forever(), telling Babel it's safe to reuse the plugin configuration across builds.
Builder Patterns with Type Inference — The TinaCMS Approach
TinaCMS shows how to use TypeScript's inference capabilities to build APIs that accumulate type information as the user configures them. Rather than requiring all type information upfront, the builder tracks it through method chaining:
// TinaCMS field builder with type inference
class FieldBuilder<TData = any> {
private config: Partial<Field> = {};
name<K extends string>(name: K): FieldBuilder<TData & Record<K, unknown>> {
this.config.name = name;
return this as any;
}
type<T extends FieldType>(type: T): FieldBuilder<TData & FieldTypeMap[T]> {
this.config.type = type;
return this as any;
}
required(required = true): FieldBuilder<TData> {
this.config.required = required;
return this;
}
build(): Field & { name: string; type: string } {
if (!this.config.name || !this.config.type) {
throw new Error('Field must have name and type');
}
return this.config as Field & { name: string; type: string };
}
}
// Usage with full type inference
const titleField = new FieldBuilder()
.name('title') // Type: FieldBuilder<{ title: unknown }>
.type('string') // Type: FieldBuilder<{ title: string }>
.required()
.build(); // Type: Field & { name: string; type: string }This pattern is particularly useful for plugin SDK configuration, where plugin authors compose many optional pieces together and autocomplete should guide them through the options rather than requiring them to know the full interface shape upfront.
Interface Extension Without Breaking Changes — The Vite Pattern
Vite's plugin interface demonstrates how to extend an existing contract — in this case, Rollup's plugin interface — without breaking anything that already works against the original:
// Base Rollup plugin interface
interface RollupPlugin {
name: string;
buildStart?: (this: PluginContext, options: InputOptions) => void;
resolveId?: (this: PluginContext, id: string, importer?: string) => string | null;
transform?: (this: TransformPluginContext, code: string, id: string) => TransformResult;
}
// Vite extends with optional additional hooks
interface VitePlugin extends RollupPlugin {
// Development server hooks
config?: (config: UserConfig, env: ConfigEnv) => UserConfig | void;
configResolved?: (config: ResolvedConfig) => void;
configureServer?: (server: ViteDevServer) => void;
// HMR hooks
handleHotUpdate?: (ctx: HmrContext) => void | HmrContext['file'][];
// Vite-specific metadata
apply?: 'build' | 'serve' | ((config: UserConfig, env: ConfigEnv) => boolean);
}
// Plugins can be used in either context
function createUniversalPlugin(): VitePlugin {
return {
name: 'universal-plugin',
// Rollup hooks work in both contexts
transform(code, id) {
return `// Universal transform\n${code}`;
},
// Vite hooks are optional and dev-server specific
configureServer(server) {
server.middlewares.use('/api', myApiHandler);
},
};
}All new fields are optional. A plugin written for Rollup works in Vite without modification. A plugin that wants to use Vite-specific features opts in by implementing the additional hooks. The extension is purely additive, which is what makes it safe.
Module augmentation takes this one step further — it lets the SDK itself be extended after the fact:
declare module './plugin-sdk' {
interface PluginSDK {
analytics?: {
trackEvent: (name: string, data?: Record<string, unknown>) => void;
};
}
}This allows a premium plugin or a host application variant to add capabilities to the SDK type without forking it. Plugins that want to use sdk.analytics can check for its presence; plugins that don't care ignore it entirely.
Conclusion
TypeScript does not make plugin architecture safe by itself. What it does is give you the vocabulary to express the safety properties you want and then enforce them consistently.
The patterns in this chapter work together. Generics let you write contracts that stay abstract where they need to be abstract. Conditional types and template literals bring that precision to event systems and hook signatures. Branded types make structurally identical strings semantically distinct. Runtime validation bridges the gap where the type system's reach runs out. And thoughtful interface design — stable, minimal, optional-heavy — determines how long those contracts can stay useful without breaking the ecosystem that depends on them.
Production systems like VS Code, Babel, Backstage, and Vite did not arrive at these patterns through theory. They arrived at them through the experience of maintaining large, public-facing APIs over many years, and the scars to show for it. Studying their choices is the fastest way to avoid repeating the hard lessons.
In the next chapter we will move from TypeScript mechanics to architectural philosophy — looking at how framework-agnostic design principles allow the SDK contracts we've defined here to work equally well regardless of whether a plugin's view layer is built in React, Vue, Angular, or anything else.