Why doesn't property x exist on type Y, even after module augmentation?

Viewed 55

I'm messing around with modules but came across this scenario that I can't fully grasp. I have everything set up like so:

import MyTesterImpl from "./MyTesterImpl";

declare module "./MyTesterImpl" {
  interface MyTesterImpl {
    augmented: boolean;
  }
}

const main = async (): Promise<void> => {
  let testy: MyTesterImpl = {
    basicA: "hey",
    basicB: "there"
  } as MyTesterImpl;

  testy.augmented = true;
};

main();

Where I have MyTestImpl defined as:

export default class MyTesterImpl {
  public basicA: string;
  public basicB: string;

  constructor(a: string, b: string) {
    this.basicA = a;
    this.basicB = b;
  }

  showStuff() {
    console.log(`${this.basicA} ${this.basicB}`);
  }
}

Using a tsconfig defined as such:

{
  "compilerOptions": {
    "target": "es2017",
    "module": "commonjs",
    "lib": ["dom", "es6", "es2017", "esnext.asynciterable"],
    "skipLibCheck": true,
    "sourceMap": true,
    "outDir": "./dist",
    "moduleResolution": "node",
    "declaration": false,
    "removeComments": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "noImplicitThis": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "resolveJsonModule": true,
    "baseUrl": ".",
  },
  "exclude": ["node_modules", "**/*.d.ts", "**/*.spec.ts"],
  "include": ["./**/*.ts"]
}

Even after adding the module augmentation in order to append the augmented field to the MyTesterImpl class, the compiler still throws the error that:

Property 'augmented' does not exist on type 'MyTesterImpl'.

You can see a reproducible example for the issue here.

I've been staring at this for hours and I can't find out why the error keeps persisting. I'm pretty positive I can do module augmentation between a class and interface as long as they share the same name, so I don't think that's the issue. And I made sure to point to the class file path when declaring the module for augmentation.

Everything looks in place to me; what am I missing here?

1 Answers

This is due to a bug in TypeScript whereby classes exported as default cannot be merged into; see microsoft/TypeScript#14080. The issue in GitHub has been open for a long time and is marked as being on the "Backlog", so I wouldn't count on it being fixed anytime soon.

A workaround mentioned in the issue is to re-export the class as a named export, and then merge into that. It would look something like

// ./MyTesterImplRepackaged.ts
import MyTesterImpl from "./MyTesterImpl";
export { MyTesterImpl };

and then

// ./index.ts
import { MyTesterImpl } from "./MyTesterImplRepackaged";

declare module "./MyTesterImplRepackaged" {
  interface MyTesterImpl {
    augmented: boolean;
  }
}

const main = async (): Promise<void> => {
  let testy: MyTesterImpl = {
    basicA: "hey",
    basicB: "there"
  } as MyTesterImpl;

  testy.augmented = true; // okay
};

It's not pretty, but it seems to work, at least for your example code.

CodeSandbox link

Related