Skip to content

Change Recipes

Jason Hartman edited this page Feb 22, 2025 · 3 revisions

Change Recipes

This is a collection of some patterns that can be used to manage changes that will eventually be or would otherwise be breaking.

Moving API to internal

Deprecating an API in order to change it to be @internal may be handled without the internal usages appearing deprecated (requiring no-deprecated lint disables). Since standard API separation is generated from a single file, a split is required with a re-tagged API to make this work.

  1. Apply the @deprecated tag to the original API. It is important to keep the original tags in place to make it clear that API is externally exposed.
  2. Create a new internal.ts source next to index.ts that re-exports everything: export * from "./index.js";.
  3. Add a new named export copying the API. Import original renamed and exported as copy.
  4. Change package.json export for /internal to internal.* instead of index.*.
  5. As needed, apply policy required changes. Try pnpm policy-check:fix.

Example: PR 23332: Making ContainerRuntime externally deprecated - see files under packages/runtime/container-runtime

Manipulating a Class or Enum

Classes and enums are both values and types and the type of (typeof) the value is not the same as the type. To clone a class or enum fully, both a type and value should be cloned.

Example

packages/runtime/container-runtime/src/internal.ts of PR 23332 avoids @deprecated for /internal version of ContainerRuntime.

import { ContainerRuntime as ContainerRuntimeClass } from "./containerRuntime.js";
export type ContainerRuntime = ContainerRuntimeClass;
export const ContainerRuntime = ContainerRuntimeClass;

Manipulating a Namespace

There is no known simple way to clone a namespace. To clone a namespace it needs redeclared member by member. So it may be advantageous to only resurface the minimal members when needed. (api-extractor may insist in a large "internal" namespace be exposed, but for /internal uses only tiny number of set actually needs surfaced.)

Example: TODO - use jason-ha's pending core-interfaces reorg for Presence infrastructure

Converting a Class to an Interface-Constructor Pair

Classes exposed outside of a package often lead to undesired maintenance burdens complicating change and evolution. When a class does not have protected members including transitive ones from extends specification, then an essentially type equivalent interface and new function may be substituted.

Warning The replacement interface will not provide exact type checking protections that the original class afforded. If the class was not already @sealed, then understand if any customer may have had reason to inherit the class.

  1. Add replacement exported interface using original class name.
    1. extends the interface by all implements specifications.
    2. Copy declaration of all class public members not covered by extends (above) into the interface.
    3. Be sure the interface is @sealed even if original class was not.
  2. Rename the class and extend from the interface.
  3. Add an exported const variable using original class name.
    1. Type as a union of
      1. new function using class constructor's parameters and returning the interface
      2. object declaration containing public static class members
    2. Assign to it the renamed class.
  4. If class had any static members that had original class types and accessed private members, there will need to be a cast (as) to renamed class.

Example

Before

export interface A {
  value: number;
}

export class B implements A {
  public readonly id: string = "instanceOfB";
  private readonly shh = 98;
  public constructor(public value: number) {}
  public static readPrivate(bThis: B): number {
    return bThis.shh;
  }
}

After

export interface A {
  value: number;
}

// Step 1
/** @sealed */
export interface B extends A {
  readonly id: string;
}

// Step 2
class BImpl implements B {
  public readonly id = "instanceOfB";
  private readonly shh = 98;
  public constructor(public value: number) {}
  public static readPrivate(bThis: B): number {
    return (bThis as BImpl).shh; // Step 4
  }
}

// Step 3
export const B: (new (value: number) => B) & {
  readPrivate: (bThis: B) => number;
} = BImpl;
Clone this wiki locally