Skip to content
Goatfied

refactoring

Cross-package renames that don't break downstream consumers

Renaming types or functions across package boundaries without breaking downstream code using type aliases, re-exports, and gradual deprecation strategies.

2026-08-278 min readBy Goatfied
Cross-package renames that don't break downstream consumers

Renaming a widely-used type or function is straightforward when all callers live in your monorepo. You run a find-and-replace, fix the stragglers, and commit. The problem starts when you maintain a library consumed by dozens of external projects: renaming Config to ServiceConfig in version 2.1.0 instantly breaks every caller that imports the old name, forcing them into emergency PRs or pinning to an older release.

The standard answer—deprecate the old name for a release or two—works but punts the problem downstream. Your consumers still have to schedule the migration, update their code, and risk merge conflicts if they delay. A better pattern treats the rename as a reversible transformation: you introduce the new name, keep the old one as a type alias or re-export, and let consumers migrate on their schedule. When you eventually remove the deprecated symbol, the diff is a single line and you've given everyone months of runway.

This post walks through the mechanics of safe cross-package renames in TypeScript, Go, Python, and Rust, the constraints that make automation reliable, and how Goatfied's compile-first loop catches edge cases before they ship.

Type aliases and re-exports as migration bridges

The simplest rename strategy is a type alias. In TypeScript, you export the new name and keep the old one pointing to it:


// Before

export interface Config {

  endpoint: string;

  timeout: number;

}



// After

export interface ServiceConfig {

  endpoint: string;

  timeout: number;

}



/** @deprecated Use ServiceConfig instead. Removed in v3.0.0. */

export type Config = ServiceConfig;

Downstream code that imports Config continues to compile and run. You've bought yourself a deprecation window—typically one or two minor versions—and consumers can migrate at their convenience. The same pattern applies to functions:


export function initializeService(cfg: ServiceConfig): Service { /* ... */ }



/** @deprecated Use initializeService instead. Removed in v3.0.0. */

export const initialize = initializeService;

Go's type aliasing works identically. If you're renaming HTTPClient to Client in a pkg/http package:


type Client struct { /* ... */ }



// Deprecated: Use Client instead. Will be removed in v3.0.0.

type HTTPClient = Client

The key is that the alias resolves at compile time, so there's zero runtime cost. Callers referencing http.HTTPClient get the same compiled code as those using http.Client.

Python's approach is more flexible because you can alias at the module level:


# api/service.py

class ServiceConfig:

    ...



# Deprecated: use ServiceConfig

Config = ServiceConfig

or use __getattr__ to intercept imports and issue warnings:


def __getattr__(name: str):

    if name == "Config":

        import warnings

        warnings.warn("Config is deprecated, use ServiceConfig", DeprecationWarning)

        return ServiceConfig

    raise AttributeError(f"module {__name__} has no attribute {name}")

Rust requires a bit more ceremony because items must be explicitly re-exported:


pub struct ServiceConfig { /* ... */ }



#[deprecated(since = "2.1.0", note = "use `ServiceConfig` instead")]

pub type Config = ServiceConfig;

In all four languages, the pattern is the same: introduce the new name, alias the old one, annotate the deprecation, and schedule removal. The tradeoff is that your public API temporarily carries both symbols, which shows up in autocomplete and documentation. That's acceptable if you communicate the timeline clearly.

Handling re-exports and transitive dependencies

The alias pattern breaks down when the renamed symbol is re-exported through intermediate packages. Suppose pkg/core defines Config, pkg/server re-exports it, and downstream consumers import from pkg/server. If you rename Config in pkg/core but forget to update the re-export in pkg/server, the alias won't propagate and consumers see a missing symbol.

You need to audit every re-export site. In TypeScript:


// pkg/core/index.ts

export interface ServiceConfig { /* ... */ }

export type Config = ServiceConfig; // deprecated



// pkg/server/index.ts

export { ServiceConfig, Config } from '../core'; // both names

Go modules make this easier because go list -json shows you which packages depend on yours:


go list -json -m all | jq -r 'select(.Path | startswith("github.com/yourorg")) | .Path'

but you still need to grep for import "yourpkg" and check what symbols are referenced. Static analysis tools like gopls can help, but they won't catch runtime reflection or dynamic imports.

The safest approach is to treat re-exports as first-class refactoring targets. When you rename Config to ServiceConfig, update every package in your monorepo that re-exports it in the same commit. If you maintain multiple libraries, cut coordinated releases and document the migration in each changelog.

Compile-time gates and rollback safety

The reason manual renames are risky is that you can't know whether you've caught every reference until you ship. A downstream consumer might be importing an undocumented symbol, or using a string literal that matches the old name, or relying on a transitive dependency that hasn't updated yet.

Goatfied's constraint-based loop enforces compile checks before any diff lands. When you run a cross-package rename, the agent generates a candidate diff, then validates it by:

1. Running tsc --noEmit or go build or cargo check across the entire workspace

2. Executing tests in affected packages

3. Checking that no new deprecation warnings appeared unless you explicitly allowed them

If the validation step fails, the agent surfaces the error, proposes a fix, and retries. This means you never merge a rename that breaks the build, even if you forgot a re-export or missed a transitive dependency.

The reversibility constraint matters just as much. Because Goatfied diffs are small and atomic, you can revert a rename with a single git revert and know that the workspace returns to a consistent state. Traditional refactoring tools bundle multiple changes—rename the type, update imports, adjust comments—into one large commit, which makes selective rollback painful.

Versioning and consumer migration timelines

Once you've aliased the old name, you need a deprecation timeline. A common policy is:

  • Minor version N: Introduce new name, alias old name with @deprecated tag
  • Minor version N+1: Keep alias, start logging warnings in documentation
  • Major version N+2: Remove alias entirely

This gives consumers at least two release cycles to migrate. If you're using semantic versioning strictly, the final removal is a breaking change and requires a major bump.

Some teams accelerate the timeline by monitoring usage. If you maintain an internal service mesh or have telemetry on which symbols are imported, you can detect when the old name's usage drops to zero and remove it early. Public libraries don't have that luxury, so err on the side of a longer deprecation window.

Document the timeline in your changelog and migration guide. A good template:


## [2.1.0] - 2024-01-15

### Deprecated

- `Config` is now `ServiceConfig`. The old name will be removed in v3.0.0.



### Migration

Replace `import { Config }` with `import { ServiceConfig }`. Type aliases

ensure backward compatibility until v3.0.0.

Automated rename workflows with constraint loops

Goatfied automates the multi-step rename by treating it as a sequence of constrained edits:

1. Plan: Identify all symbols to rename, re-export sites, and test files that reference them

2. Constrain: Define validation rules (compile, lint, tests pass; no new warnings except expected deprecations)

3. Edit: Generate diffs for the new name, aliases, and updated imports

4. Validate: Run checks across the workspace

5. Retry: If validation fails, surface errors and propose fixes

The key difference from a traditional LSP rename is that Goatfied retries when constraints fail. If the agent renames Config but misses a string literal "Config" in a JSON schema, the validation step catches it, and the next iteration adds that file to the diff.

You can scope the rename to specific packages or allow the agent to expand the blast radius incrementally. The audit log records every iteration, so you can review exactly what changed and why.

Related posts

Cross-package renames that don't break downstream consumers | Goatfied Blog