Narrowing with in and tagged interfaces for a plugin system
Published June 29, 2027
interface LoggerPlugin { kind: "logger"; log(message: string): void }
interface CachePlugin { kind: "cache"; get(key: string): unknown }
export type AppPlugin = LoggerPlugin | CachePlugin;
export const runPlugin = (plugin: AppPlugin, key: string): void => {
if ("log" in plugin) plugin.log(`running for ${key}`);
else console.log(plugin.get(key));
};What
LoggerPlugin and CachePlugin each carry a kind tag, the way any discriminated union does — but runPlugin doesn't switch on kind at all. It narrows with "log" in plugin, the in operator, checking for a method name instead of a discriminant value.
Why it matters
A switch (plugin.kind) works well when a union has a small, closed set of tags and every branch needs to check all of them. Plugin systems often don't fit that: plugins may support overlapping capabilities, new plugin kinds get added by third parties who can't edit your switch statement, and code that only cares about "does this plugin support logging" shouldn't need to enumerate every kind that doesn't. in narrows structurally, based on what's actually present on the value, which matches how a lot of real plugin architectures are actually consumed — by capability, not by exhaustively naming every plugin kind.
How it works
"log" in pluginis a runtime check (the actualinoperator, testing property presence) that TypeScript also understands as a type guard: inside theif,pluginis narrowed to the union members that have alogproperty — here, justLoggerPlugin.- The
elsebranch is narrowed by elimination: whatever's left ofAppPluginonceLoggerPluginis excluded isCachePlugin, soplugin.getis available there without an explicit check. - The
kindfield isn't used for narrowing at all in this function — it's still useful for other consumers that do want to switch exhaustively (logging which plugin ran, say), and for debugging, butrunPluginshows that a tagged union doesn't obligate every consumer to narrow via the tag.
Gotchas
innarrows based on the type's declared properties, not a runtime check on what's actually at that property —"log" in plugindoesn't confirmplugin.logis a function, only that the type says alogproperty exists. For values that didn't come from a trusted internal source, pair this with the type-guard-and-validate pattern instead.innarrowing gets less useful once two union members share a same-named property with different types — TypeScript can still narrow, but the result may be a union of the overlapping members rather than a single one, which limits how far a branch can simplify.
Related
- Discriminated unions with an exhaustive switch — the tag-based alternative; better when you want the compiler to force you to handle every variant explicitly.
unknown+ a type guard — for plugin values that aren't already known to be one of the union's members, such as ones loaded dynamically at runtime.