How to consume a d.ts file's exported const types if the library's implementation is not integrated with the TS project?
Asked Answered
B

2

19

DefinitelyTyped has type definitions for many libraries, but often enough I cannot find a good way to use them when the Javascript implementation is separated from the Typescript, like when a library assigns itself to a property of the window through a

<script src="https://example.com/library.js">

tag, and when the JS bundle I'm managing is in another separate script. (Even though bundling everything together including the library is the standard and reliable method, assume for the sake of the question that I do not have the option of importing the library into my TS project proper.) For example, say I find a nice looking definition file for a library named myLib:

// my-lib.d.ts
export const doThing1: () => number;
export const doThing2: () => string;
export const version: string;
export interface AnInterface {
  foo: string;
}
export as namespace myLib;

In JS, I can use myLib by calling window.myLib.doThing1() and window.myLib.doThing2(). How may I import the shape of the whole window.myLib object so that I can declare it as a property of window? I can see that I can import the exported interfaces, eg:

// index.ts
import { AnInterface } from './my-lib';
const something: AnInterface = { foo: 'foo' };
console.log(something.foo);

This works, but I want access to the shape of the actual library object and its property values (the functions and strings and such), not just the interfaces. If I do

import * as myLib from './my-lib';

then the myLib identifier becomes a namespace, from which I can reference the exported interfaces, but just like above, I still have no access to the export const and export function shapes from my-lib.d.ts. (And, of course, trying to use the imported namespace to declare the library object doesn't work: Cannot use namespace 'myLib' as a type. Even if I could do that, that wouldn't necessarily be safe, because the library packaged for the browser may well be structured slightly differently from the library's Node export object)

If I manually copy and paste parts of the d.ts into my own script, I can hack together something that works:

// index.ts
declare global {
  interface Window {
    myLib: {
      doThing1: () => number;
      doThing2: () => string;
      version: string;
    };
  }
}

But this is messy, time-consuming, and surely not the proper way to go about doing something like this. When I encounter this sort of situation, I would like do be able to do something short and elegant like:

// index.ts
import myLibObjectInterface from './my-lib.d.ts'; // this line is not correct
declare global {
  interface Window {
    myLib: myLibObjectInterface
  }
}

Some definition files include an interface for the library object, like jQuery, which does:

// index.d.ts
/// <reference path="JQuery.d.ts" />

// jQuery.d.ts
interface JQuery<TElement = HTMLElement> extends Iterable<TElement> {
  // lots and lots of definitions

Then everything's just fine - I can just use interface Window { $: jQuery }, but many libraries not originally created for browser consumption do not offer such an interface.

As mentioned before, the best solution would be for the library's implementation to be integrated with the TS project, allowing for both the library and its types to be imported and used without fuss, but if that's not possible, do I have any good options left? I could examine the properties on the real library object and add an interface to the definition file which includes all such properties and their types, but having to modify the semi-canonical source definition file(s) accepted by DT and used by everyone else feels wrong. I'd prefer to be able to import the shapes of the definition file's exports, and create an interface from them without modifying the original file, but that may not be possible.

Is there a more elegant solution, or are the definition files I've happened to come across simply insufficiently suited for my goal, and thus must be modified?

Bork answered 21/4, 2019 at 0:37 Comment(0)
M
13

If the module has an export as namespace myLib then module already exports the library as a global object. So you can just use the library as:

let a:myLib.AnInterface;
let b =  myLib.doThing1();

This is true as long as the file you are using the library in is not a module itself (ie it contains no import and no export statements).

export {} // module now
let a:myLib.AnInterface; // Types are still ok without the import
let b =  myLib.doThing1(); // Expressions are not ok, ERR: 'myLib' refers to a UMD global, but the current file is a module. Consider adding an import instead.ts(2686)

You can add a property to Window that is the same type as the type of the library using an import type (added in 2.9 in believe)

// myLibGlobal.d.ts
// must not be a module, must not contain import/ export 
interface Window {
    myLib: typeof import('./myLib') // lib name here
}


//usage.ts
export {} // module
let a:myLib.AnInterface; // Types are still ok without the import (if we have the export as namespace
let b =  window.myLib.doThing1(); // acces through window ok now

Edit

Apparently the Typescript team has actually been working on something for this very issue. As you can read in this PR the next version of typescript will include a allowUmdGlobalAccess flag. This flag will allow access to UMD module globals from modules. With this flag set to true, this code will be valid:

export {} // module now
let a:myLib.AnInterface; // Types are still ok without the import
let b =  myLib.doThing1(); // ok, on [email protected]

This mean you can just access the module exports without the need for using window. This will work if the global export is compatible with the browser which I would expect it to be.

Mccollum answered 23/4, 2019 at 8:31 Comment(9)
I don't understand why typeof can be safely used thereBengaline
@CristianTraìna not sure what you mean ? import('./myLib') allows us to access the types in a module without importing. typeof import('./myLib') gives us the type of the import object without actually importing it.Mccollum
Thank you, this is very helpful! My consumers were always modules, since it's part of a larger project, I didn't think of trying to create a non-module file with side-effects only, which just feels strange to me.Bork
So, to avoid modifying the original definition file, in a non-module, either create a (global) variable whose properties reference the library's (const myLibObj = { doThing1: myLib.doThing1, ...), or create a (global) interface which can be used later, interface MyLib = { doThing1: typeof import('myLib').doThing1 ..., allowing the library's location can be described more flexibly.Bork
@Bork you don't need to spell out all the members of the library (although you can if you want), typeof import('myLib') represents an object containing all the value exports of the module (basically the type of module.exports). What you do with this type is up to you, typescript will only help you describe this in types, it will not in any way make sure the module is there at runtime (ie it does not care about how myLib comes to exist on Window at runtime, you have to make sure it's there, and from my reading of the question window.myLib already worked, it was just a typing issue)Mccollum
@Bork also, just so make sure this point got across, the file containing the extra Window definition is just a definition file, no runtime behavior whatsoever.Mccollum
Yep, it's just that the library's export in Node may not exactly match the object that's put onto the window in the bundled web version, so listing out the properties may be needed for accuracy.Bork
@Bork that may be I don't know. If you want to pick some properties, Pick and Omit may help you write less. Especially if the window version just excludes one or two members from the node version, but this is all library dependent I guessMccollum
@Bork apparently the TS team has been working on exactly this scenario, see my edit :)Mccollum
M
3

What are you dealing with

when a library assigns itself to a property of the window

That's called a UMD package. These are the ones consumed by adding a link inside a <script /> tag in your document, and they attach themselves to the global scope.

UMD packages don't have to be consumed this way — they can also be consumed as modules, using an import (or require) statement.

TypeScript supports both usages.

How should UMD packages be typed

declare namespace Foo {
  export const bar: string;
  export type MeaningOfLife = number;
}

export as namespace Foo;
export = Foo;

This definition tells TypeScript that:

  • if the consuming file is a script, then there's a variable called bar in the global scope
  • if the consuming file is a module, then you can import bar using named imports or import the entire namespace using the wildcard (*) import.

What's the difference between a script and a module?

A script would be a piece of JavaScript running inside a <script /> tag in your HTML document. It can be inlined or loaded from a file. It's how JavaScript has always been used in the browser.

A module is a JavaScript (TypeScript) file that has at least one import or export statement. They are part of the ECMAScript standard and are not supported everywhere. Usually, you create modules in your project and let a bundler like Webpack create a bundle for your application to use.

Consuming UMD packages

A script, variables, and types are used by accessing the global Foo namespace (just like jQuery and $):

const bar = Foo.bar;
const meaningOfLife: Foo.MeaningOfLife = 42;

A script, the type of bar is imported using the import type syntax:

const baz: typeof import ('foo').bar = 'hello';

A module, the bar variable is imported using a named import.

import { bar } from 'foo';

bar.toUpperCase();

A module, the entire package is imported as a namespace:

import * as foo from 'foo';

foo.bar.toUpperCase();

Global scope vs. window

If such a library is typed correctly, you as the consumer don't have to do anything to make it work. It will be available in your global scope automatically and no augmentation for Window will be necessary.

However, if you'd like to attach the contents of your library to window explicitly, you can also do that:

declare global {
  interface Window {
    Foo: typeof import('foo');
  }
}

window.Foo.bar;
Mohammedanism answered 23/4, 2019 at 10:28 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.