Skip to content

feat: type-safe environment-dependent plugins #4096

Open
@buschtoens

Description

@buschtoens

Feature Request

Description

Right now plugins make themselves available by augmenting the PluginRegistry interface in the @capacitor/core module, for instance like this:

// definitions.d.ts

declare module '@capacitor/core' {
  interface PluginRegistry {
    Foo: FooPlugin;
  }
}

export interface FooPlugin {
  getFoo(): Promise<'foo'>;
  getBar(): Promise<'bar'>;
  getCurrentPlatform(): Promise<'android' | 'ios' | 'web'>;
}

The plugin is included in the registry unconditionally. When a plugin provides uniform implementations for all three supported platforms, this is not an issue.

However, there are plugins which are not available on all platforms or have varying interfaces per platform. For instance, the above could be expressed in a different way, if the registries for each platforms were disjunct.

// definitions.d.ts

declare module '@capacitor/core' {
  interface PluginRegistryAndroid {
    Foo: FooPluginAndroid;
  }
  interface PluginRegistryIOS {
    Foo: FooPluginIOS
  }
  interface PluginRegistryWeb {
    Foo: FooPluginWeb;
  }
}

export interface FooPlugin {
  getFoo(): Promise<'foo'>;
  getBar(): Promise<'bar'>;
  getCurrentPlatform(): Promise<'android' | 'ios' | 'web'>;
}

export interface FooPluginAndroid extends FooPlugin {
  getCurrentPlatform(): Promise<'android'>;
}

export interface FooPluginIOS extends FooPlugin {
  getCurrentPlatform(): Promise<'ios'>;
}

export interface FooPluginWeb extends FooPlugin {
  getCurrentPlatform(): Promise<'web'>;
}

Through some nifty type for Capacitor.Plugins we could then achieve a stricter, safer usage, like this:

import { Plugins } from "@capacitor/core";

declare module "@capacitor/core" {
  interface PluginRegistry {
    LegacyPlugin: { name: "LegacyPlugin" };
  }

  interface PluginRegistryAndroid {
    AndroidPlugin: { name: "AndroidPlugin" };
    NativePlugin: { name: "NativePlugin"; platform: "android" };
  }

  interface PluginRegistryIOS {
    IOSPlugin: { name: "IOSPlugin" };
    NativePlugin: { name: "NativePlugin"; platform: "ios" };
  }
  
  interface PluginRegistryWeb {
    WebPlugin: { name: "WebPlugin" };
  }
}

// Plugins registered in the legacy `PluginRegistry` are always optional, as
// there is no way to guarantee that they are available. To make them
// accessible without `?.` the plugin author must migrate them to one or more of
// the new registries
Plugins?.LegacyPlugin;  // undefined | { name: "LegacyPlugin" }

// Without narrowing the type down via one of the `.is*` platform
// discriminators, all plugins are available as optional, meaning you have to
// use `?.` when accessing them.
Plugins?.AndroidPlugin; // undefined | { name: "AndroidPlugin" }
Plugins?.IOSPlugin;     // undefined | { name: "IOSPlugin" }
Plugins?.NativePlugin;  // undefined | { name: "NativePlugin"; platform: "android" | "ios" }
Plugins?.WebPlugin;     // undefined | { name: "WebPlugin" }

if (Plugins.isAndroid) {
  Plugins.isIOS; // false
  Plugins.isWeb; // false

  // Android-only plugin is available.
  Plugins.AndroidPlugin; // { name: "AndroidPlugin" }
  
  // Common native plugin is available and narrowed down to the Android flavor.
  Plugins.NativePlugin;  // { name: "NativePlugin"; platform: "android" };

  // As before, plugins registered in the legacy `PluginRegistry`, are always
  // optional and you have to use `?.`.
  Plugins.LegacyPlugin;  // undefined | { name: "LegacyPlugin" }

  // Plugins that are specific to a platform are not accessible.
  Plugins.IOSPlugin;     // undefined
  Plug

buschtoens/capacitor-plugin-registry/blob/main/demo.ts

Platform(s)

Android, iOS, Web

Preferred Solution

import type { Merge } from "type-fest";

interface PlatformDiscriminator {
  isAndroid: boolean;
  isIOS: boolean;
  isWeb: boolean;
}

// Original legacy registry remains, so that legacy addons can still register themselves.
interface PluginRegistry {}

interface PluginRegistryAndroid {
  isAndroid: true;
  isIOS: false;
  isWeb: false;
}

interface PluginRegistryIOS {
  isAndroid: false;
  isIOS: true;
  isWeb: false;
}

interface PluginRegistryWeb {
  isAndroid: false;
  isIOS: false;
  isWeb: true;
}

// -------------------- Private helper types ------------------------
// These are not meant to be augmented directly by plugins.
// To prevent this, they may be moved to extra files.

type AllPlugins = Partial<
  Merge<
    PluginRegistry,
    Merge<PluginRegistryAndroid, Merge<PluginRegistryIOS, PluginRegistryWeb>>
  >
> &
  PlatformDiscriminator;

type NoPlugins = Record<
  | keyof PluginRegistryAndroid
  | keyof PluginRegistryIOS
  | keyof PluginRegistryWeb,
  undefined
>;

type CapacitorPluginRegistry =
  | AllPlugins
  | (Partial<PluginRegistry> & Merge<NoPlugins, PluginRegistryAndroid>)
  | (Partial<PluginRegistry> & Merge<NoPlugins, PluginRegistryIOS>)
  | (Partial<PluginRegistry> & Merge<NoPlugins, PluginRegistryWeb>);

// This is `Capacitor.Plugins`
export const Plugins: CapacitorPluginRegistry;

buschtoens/capacitor-plugin-registry/blob/main/index.d.ts

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions