Typing functions which might not exist
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. 🦾