Typing Functions Which Might Not Exist

Josh Farrant

When working on customer-facing applications, it's not uncommon to find ourselves calling functions which have been added to the window by a third-party script. Perhaps a script has been added to our website which exposes a global function for us to call to fire off a tracking event; Google Tag Manager's window.dataLayer, for example, or RudderStack's window.rudderanalytics.

Examples in this post will use Google Tag Manager's window.dataLayer object, but the same principle can be applied to any global.

The problem

TypeScript has no way of knowing what functions have been added to the window object, and so it isn't going to be happy if we try to call an unknown function from within our application.

window.dataLayer.push({ button: 'clicked' });

// Property 'dataLayer' does not exist on
// type 'Window & typeof globalThis'. ts(2339)

To solve this, we could use a global.d.ts file to let TypeScript know about global variables, or we could use type casting to extend the window type with our additional properties.

type TDataLayer = {
  push: (data: Record<string, unknown>) => void;
};

type TWindowWithDataLayer = typeof window & {
  dataLayer: TDataLayer;
};

// Force TypeScript to accept that dataLayer exists
(window as TWindowWithDataLayer).dataLayer.push({
  button: 'clicked',
});

Both of these approaches will absolutely work from a type perspective, however the front-end world is a world of uncertainty. As browsers add more and more privacy-focussed features, and as users become more privacy-concious and more frequently install ad blockers, we can't guarantee that the Google Tag Manager script which adds the dataLayer global will actually run, meaning we can't know for certain whether or not there will be a dataLayer object on the window. This puts us in an awkward position where we've told TypeScript that dataLayer will always be there, when in reality it might not.

The solution

One solution to the above problem that I've found myself gravitating towards would be to create a withDataLayer function.

// A simplified implementation of the dataLayer object
type TDataLayer = {
  push: (data: Record<string, unknown>) => void;
};

type TWindowWithDataLayer = typeof window & {
  dataLayer: TDataLayer;
};

// A type guard to determine whether or not window has a dataLayer
const windowHasDataLayer = (
  providedWindow: Window,
): providedWindow is TWindowWithDataLayer =>
  typeof providedWindow !== 'undefined' &&
  typeof (providedWindow as TWindowWithDataLayer)
    .dataLayer !== 'undefined';

// Call the provided callback if (and only if) the window has a dataLayer
const withDataLayer = (
  callback: (dataLayer: TDataLayer) => void,
): void => {
  if (windowHasDataLayer(window)) {
    callback(window.dataLayer);
  }
};

// To push an event to the dataLayer we can do the following
withDataLayer(({ push }) =>
  push({
    button: 'clicked',
  }),
);

Let's break this down.

Our windowHasDataLayer function is a type guard (denoted by the is in the return type) which will check for the existence of the dataLayer object on the window and return a boolean based on its existence.

Our withDataLayer function then calls the provided callback if and only if windowHasDataLayer returns true. Since we've defined a type guard, TypeScript then knows that the window object must have the dataLayer object on it, and so when we then try to use the push function on that object in the last line of the above snippet TypeScript happily lets us.

The key point here is that the callback passed into withDataLayer will only be called when dataLayer exists on the window, and if it does exist then the callback gets passed the properly-typed dataLayer object.

The result is that we can call the push function that withDataLayer exposes from anywhere in our application without having to worry about whether or not the Google Tag Manager script actually loaded.

withDataLayer(({ push }) =>
  push({
    button: 'clicked',
  }),
);

We could take this one step further and create a pushToDataLayer function which exposes the functionality in the above snippet in a more concise form.

const pushToDataLayer = (
  data: Record<string, unknown>,
): void => withDataLayer(({ push }) => push(data));

pushToDataLayer({ button: 'clicked' });

This is an even cleaner implementation which now has exactly the same signature as the original dataLayer.push function that Google Tag Manager exposes with the added benefit that it will only run when dataLayer is defined! 🎉

Making it generic

We can take this yet another step further and create a generic implementation that's not coupled to dataLayer at all.

type EnhancedWindow<
  T extends {},
  K extends string,
> = Window & {
  [key in K]: T;
};

const windowHasProperty = <T extends {}, K extends string>(
  providedWindow: Window,
  key: K,
): providedWindow is EnhancedWindow<T, K> =>
  typeof providedWindow !== 'undefined' &&
  typeof (providedWindow as EnhancedWindow<T, K>)[key] !==
    'undefined';

const withWindowProperty = <T extends {}>(
  key: string,
  callback: (property: T) => void,
): void => {
  if (windowHasProperty<T, typeof key>(window, key)) {
    callback(window[key]);
  }
};

This is exactly the same solution as above, but with the Google Tag Manager and dataLayer specifics removed to make the solution more Generic. See what I did there?

We could then use our new withWindowProperty function to call the dataLayer.push event.

type DataLayer = {
  push: (data: Record<string, unknown>) => void;
};

// Push an event to the dataLayer, if it exists
withWindowProperty<DataLayer>('dataLayer', ({ push }) =>
  push({ button: 'clicked' }),
);

// Alternatively, we could create a helper function with a cleaner signature
const pushWithDataLayer: DataLayer['push'] = data =>
  withWindowProperty<DataLayer>('dataLayer', ({ push }) =>
    push(data),
  );

// A cleaner signature using our helper function
pushWithDataLayer({ button: 'clicked again' });

This generic solution can be reused anywhere we want to call a function which may or may not actually exist on the window in a clean and type-safe way. 🦾