Right way to reference platform-specific types in isomorphic Typescript library

1.2k views Asked by At

I'm trying to write a Typescript library that I'd like to be able to include when targeting both the browser and Node. I have two problems: referring to platform-specific types in the body of the code, and the inclusion of those types in the generated .d.ts declarations that accompany the transpiled JS.

In the first case, I want to write something like

  if (typeof window === "undefined") {
    // Do some Node-y fallback thing
  } else {
    // Do something with `window`
  }

This fails to compile if I don't include "dom" in my lib compiler option (that is, if I just say lib: ["es2016"] in tsconfig), because the global window is not defined. (Using window is just an example of something out of lib.dom.d.ts, it may also be fetch or a Response or Blob, etc.) The point is that the code should already be safe at runtime by checking for the existence of the global object before using it, it's the type side that I can't figure out.

In the second case, I'm getting an error trying to include the library after it builds. I can build the library using "dom" in the lib option, and the resulting output includes typings with e.g. declare export function foo(x: string | Blob): void. The problem is, if the consuming code doesn't include a definition for Blob (no "dom" lib), it fails to compile, even though it's only actually calling foo with a string (or not using foo at all!).

I don't want my library (or the consumer) to try to pollute the global namespace with fake window or Blob declarations if I can help it. More isometric libraries have been popping up but I haven't found a good Typescript example to follow. (If it's too complex a topic for SO, I'd still greatly appreciate a pointer to documentation or an article/blog post.)

3

There are 3 answers

18
Sayan Pal On

I think that this is a classic case for abstraction and a straightforward one. That is, you code against a IPlatform interface and refer to that interface in your code. The interface in turn hides all the platform specific implementations.

You can also additionally expose APIs so that consumers can easily initialize the "platform", usually with the appropriate "global" object. Employ dependency injection to inject the correct (platform-specific) instance of IPlatform to your code. This should reduce branching in your code severely and keep your code cleaner. You don't have to pollute your code with fake declarations with that approach, as you have pointed out in your question.

Optionally, you can also export the IPlatform instance from your package so that the consumers can also reap the benefit of that.

The second problem you mentioned:

The problem is, if the consuming code doesn't include a definition for Blob (no "dom" lib), it fails to compile, even though it's only actually calling foo with a string (or not using foo at all!).

I think this can be easily countered by installing @types/node as devDependency on the consumer side. That should be of relatively low footprint, as that will not add to a consumer's bundle.

12
jsejcksn On

In response to your clarifying comments, I now understand your question to be: "How can I use global types from an environment without including those environment type libraries in my own library?"


A truly isomorphic module is agnostic to the environment.

You should simply define the types that you intend to use from TS language built-ins. In this way, your code targets literal shapes, not environments.

This is not terse, however, a positive side-effect of this method is that your code will never break because of updates to an environment's library declaration files.

You can use documentation and examples to explain how to use library types with your modules.

Here are some (contrived) examples:


When typing parameters, use types which represent only the parts of the values which you actually use in your code:

TS Playground

/**
 * Here, `value` is string, or an object type which is extended by an instance
 * of `globalThis.Blob` in a browser:
 */
async function asText (value: string | { text(): Promise<string> }): Promise<string> {
  return typeof value === 'string' ? value : value.text();
};

When returning values from (or which use) global library types from an environment, test for the existence and shape of the value (structurally, just like TypeScript does), and then use utilities to get the types directly from the environment:

TS Playground

type GT = typeof globalThis;
type PropValue<T, K> = K extends keyof T ? T[K] : never;

function getWindowFetch (): PropValue<GT, 'fetch'> {
  if (typeof (globalThis as any).window?.fetch !== 'function') {
    throw new TypeError('window.fetch not in scope');
  }
  return (globalThis as any).window?.fetch as PropValue<GT, 'fetch'>;
}

async function fetchBlob (url: string): Promise<InstanceType<PropValue<GT, 'Blob'>>> {
  const f = getWindowFetch(); // `typeof globalThis.fetch` in browser/Deno, and `never` in Node (will throw)
  const response = await f(url);
  return response.blob() as Promise<InstanceType<PropValue<GT, 'Blob'>>>;
}

const blob = fetchBlob('ok'); // `Promise<Blob>` in browser/Deno, and `never` in Node (will throw)

A classic example: base64 string conversion functions:

TS Playground

function utf8ToB64 (utf8Str: string): string {
  if (typeof (globalThis as any).window?.btoa === 'function') { // browser/Deno
    return (globalThis as any).window.btoa(utf8Str);
  }
  else if (typeof (globalThis as any).Buffer?.from === 'function') { // Node
    return (globalThis as any).Buffer.from(utf8Str).toString('base64');
  }
  throw new TypeError('Runtime not supported');
}

function b64ToUtf8 (b64Str: string): string {
  if (typeof (globalThis as any).window?.atob === 'function') { // browser/Deno
    return (globalThis as any).window.atob(b64Str);
  }
  else if (typeof (globalThis as any).Buffer?.from === 'function') { // Node
    return (globalThis as any).Buffer.from(b64Str).toString('utf8');
  }
  throw new TypeError('Runtime not supported');
}

utf8ToB64('héllo world'); // 'aOlsbG8gd29ybGQ='
b64ToUtf8('aOlsbG8gd29ybGQ='); // 'héllo world'
b64ToUtf8(utf8ToB64('héllo world')); // 'héllo world'

How you decide to structure your library and its exports depends on your preferences.

1
John On

Unfortunately, there isn't a great way to do this currently.

The approach suggested by members of the TypeScript team is to take advantage of the interface merging feature of TypeScript, and "forward declare" interfaces in the global scope which can potentially merge with the declarations of the same interface in the consuming environment.

One common example of this might be the Buffer type built into Node.js. A library that operates in both browser and Node.js contexts can state that it handles a Buffer if given one, but the capabilities of Buffer aren't important to the declarations.

export declare function printStuff(str: string): void;

/**
 * NOTE: Only works in Node.js
 */
export declare function printStuff(buff: Buffer): void;

One technique to get around this is to "forward declare" Buffer with an empty interface in the global scope which can later be merged.

declare global {
    interface Buffer {}
}

export declare function printStuff(str: string): void;

/**
 * NOTE: Only works in Node.js
 */
export declare function printStuff(buff: Buffer): void;

This approach has some problems, as elaborated on in the issue linked above:

  • It only works for classes and interfaces, not other types such as union types.
  • Interface merging doesn't always correctly resolve conflicts between declarations in two interfaces.

The TypeScript team has proposed a new feature called "Placeholder Type Declarations" to address these issues, but it doesn't appear to be on the roadmap currently.