Augment module declarations with types from an external module
Asked Answered
W

1

4

Context

I'm making a React renderer for NativeScript (i.e. a library that allows you to declare NativeScript UIs using React), and I want to provide typings for it.

React's typings already fully support React DOM (i.e. there's references to HTMLElements throughout), but I need to augment the typings to make them aware of NativeScript elements, too. To provide accurate typings, I need to provide references to external modules, e.g. by importing ActionBar. My attempt looks something like this (which does compile, but isn't picked up by the other modules in my project):

// react-nativescript/index.d.ts
import { ActionBar } from "tns-core-modules/ui/action-bar/action-bar";

declare namespace JSX {
    interface IntrinsicElements {
        actionBar: React.ClassAttributes<ActionBar>
          & React.NativeScriptAttributes<ActionBar>
    }
}

declare namespace React {
    interface NativeScriptAttributes<T> {
        className?: string;
        children?: ReactNode;
        onClick?: MouseEventHandler<T>;
    }

// Omitting DetailedNativeScriptFactory<P, T> for brevity.
    interface ReactNativeScript {
        actionBar: DetailedNativeScriptFactory<
            NativeScriptAttributes<ActionBar>,
            ActionBar
        >,
    }
}

Root of the problem

Adding the top-level import ActionBar changes the file from a 'script' to a 'module':

In TypeScript, just as in ECMAScript 2015, any file containing a top-level import or export is considered a module. Conversely, a file without any top-level import or export declarations is treated as a script whose contents are available in the global scope (and therefore to modules as well).

Further clarification of terminology is provided by TypeScript Contributor Mohamed Hegazy.

Upon importing it, the declarations cease to be available to other modules. That is to say, I can only use the type React.ReactNativeScript when my file lacks top-level imports/exports. Otherwise, the following error appears:

Namespace 'React' has no exported member 'ReactNativeScript'.

NOTE: this may be incorrect configuration on my part of my tsconfig.json, but I have tried just about every combination of tsconfig typeRoots and package.json types at this point and nothing makes a difference.

(Failing) alternative approach

In order to keep the declarations available to other modules, we can try moving the ActionBar imports into the namespaces themselves, e.g.:

// react-nativescript/index.d.ts
declare namespace JSX {
    import { ActionBar } from "tns-core-modules/ui/action-bar/action-bar";
    import { ClassAttributes, NativeScriptAttributes } from "react";
    interface IntrinsicElements {
        actionBar: ClassAttributes<ActionBar>
          & NativeScriptAttributes<ActionBar>
    }
}

However, this introduces another compilation error:

Import declarations in a namespace cannot reference a module

... not to mention hundreds of other resulting compiler errors that I'd rather not dig into.

Known issue?

I found this seemingly related issue: https://github.com/Microsoft/TypeScript/issues/4166

Our current answer today is "Move your stuff to another .d.ts file". This is an OK workaround, but not great. The real sticker is when there's a type defined inside an external module -- it's impossible to use those types to augment a global interface, even though this is a thing that actually happens. It gets even trickier because this encourages people to move types into the global namespace when they didn't want to in the first place, exacerbating the problem.

I don't really know what is meant by "Move your stuff to another .d.ts file". I began trying to declare everything I needed in global scope (essentially building a lib.dom.d.ts for NativeScript), but NativeScript is simply too big to take that approach – and it would be a nightmare if they ever updated their typings.

Question

How can I augment React's typings with references to external modules like tns-core-modules?

It seems like my initial approach would work if only TypeScript would expose that file to all other files within my library (and any projects that consume it), but no matter my tsconfig.json settings, the declarations file is only noticed by other modules if it is a script rather than a module.

Wouldbe answered 14/4, 2019 at 13:9 Comment(5)
Couldn't you instead of importing use ///<reference path="/path/to/types.d.ts">? That shouldn't turn the script into a module.Agenesis
@m93a That's legal, but I did briefly attempt it and couldn't see how to write it for the case of ActionBar. Another problem is that, when using a reference path, you have no control over the aliasing of the modules that you'd import, so you'd be stumped the moment you'd run into a module name clash. I think I'd need to see a proof of concept to be convinced that this approach could work at all.Wouldbe
There might be some overlap with the question I asked recently. I'm looking forward to see both of these questions answered.Agenesis
Have you tried ///<reference types="tns-core-modules/ui/action-bar/action-bar" />? [Reference.]Agenesis
I did briefly investigate it, but I think I gave up fairly quickly because I could see that it could lead to unresolvable namespace clashes (I'd be referencing dozens of these files, and there's a good chance that two of them would have exports with the same name). This would also pollute your global namespace with lots of interfaces and types that you're not interested in. Ultimately, they're modules, so I want to keep them modular.Wouldbe
J
0

Have you tried declare global similar to the below?

// react-nativescript/index.d.ts
import { ActionBar } from "tns-core-modules/ui/action-bar/action-bar";

declare global {
  declare namespace JSX {
    interface IntrinsicElements {
      actionBar: React.ClassAttributes<ActionBar>
        & React.NativeScriptAttributes<ActionBar>
    }
  }

  declare namespace React {
    interface NativeScriptAttributes<T> {
      className?: string;
      children?: ReactNode;
      onClick?: MouseEventHandler<T>;
    }

  // Omitting DetailedNativeScriptFactory<P, T> for brevity.
    interface ReactNativeScript {
      actionBar: DetailedNativeScriptFactory<
        NativeScriptAttributes<ActionBar>,
        ActionBar
      >,
    }
  }
}

export {} // this is needed for the file to be considered a module
// so that it isn't erased during compilation

Note that declare may or may not be required on some or all type/interface/namespace declarations. It could be required, given the TS docs on merging namespaces. But it might not be required, given the TS docs on global augmentation.

Jury answered 30/1, 2023 at 0:12 Comment(2)
I eventually solved it by removing the top-level imports and using inline type imports instead (actionBar: DetailedNativeScriptFactory<NativeScriptAttributes<import("tns-core-modules/ui/action-bar/action-bar").ActionBar>, import("tns-core-modules/ui/action-bar/action-bar").ActionBar>). But I'll keep that declare global trick in mind for future, thanks!Wouldbe
I honestly don't know if it would solve your use-case. I'm struggling with TypeScript a bit, myself, right now. But learning does occur over time...Jury

© 2022 - 2024 — McMap. All rights reserved.