10LOC
TypeScriptadvanced

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 plugin is a runtime check (the actual in operator, testing property presence) that TypeScript also understands as a type guard: inside the if, plugin is narrowed to the union members that have a log property — here, just LoggerPlugin.
  • The else branch is narrowed by elimination: whatever's left of AppPlugin once LoggerPlugin is excluded is CachePlugin, so plugin.get is available there without an explicit check.
  • The kind field 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, but runPlugin shows that a tagged union doesn't obligate every consumer to narrow via the tag.

Gotchas

  • in narrows based on the type's declared properties, not a runtime check on what's actually at that property — "log" in plugin doesn't confirm plugin.log is a function, only that the type says a log property exists. For values that didn't come from a trusted internal source, pair this with the type-guard-and-validate pattern instead.
  • in narrowing 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.