Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save elektronik2k5/8b7543b207e08495a42e9a0d5df79d6d to your computer and use it in GitHub Desktop.
Save elektronik2k5/8b7543b207e08495a42e9a0d5df79d6d to your computer and use it in GitHub Desktop.

Nullable fields and explicit initialization

In order to allow modularity, enable testability without mocking (which is always a PITA) and keep platform code out of our models, we initialize relevant fields with an explicit null value. However, the flip side is that we must also deal with these null values. The way to do that is via TS's type guards. Here's a real (partial) example:

// A nullable field which can hold a reference to a platform specific API, which may not exist in the test environment.
type MaybeStorage = Storage | null;

interface WithLocalStorage {
  storage: MaybeStorage;
}

class LocalStorageModel implements WithLocalStorage {
  static create = (...args: ConstructorParameters<typeof this>) => new this(...args);
  // We initialize the field with `null`, so we can always create an instance.
  storage: MaybeStorage = null;
  setStorage = (storage: typeof this.storage): void => {
    this.storage = storage;
  };
  // A type guard which will assert that the field is not null.
  assertHasStorage = (maybeStorage: typeof this.storage): maybeStorage is Storage => {
    const hasStorage = maybeStorage !== null;
    if (!hasStorage) {
      console.warn("There's no storage. Can't read or write - this is likely a bug.", { maybeStorage, hasStorage, this: this });
    }
    return hasStorage;
  };
  // A method which needs an implementation of the field to be set in order to work.
  serializeAndSet = ({ key, value }: { key: string; value: unknown }): boolean => {
    // Check whether the platform specific API is available.
    if (!this.assertHasStorage(this.storage)) {
      // Handle the case when it isn't.
      return false;
    }
    // Use it, knowing that it is.
    const context = { key, value, this: this };
    try {
      const serializedValue = JSON.stringify(value);
      this.storage.setItem(key, serializedValue);
      return true;
    } catch (ex) {
      console.warn(`Failed to serialize or store value of key '${key}'.`, { ex, ...context });
      return false;
    }
  };

  constructor({ storage }: WithLocalStorage) {
    makeAutoObservable(this, { setStorage: action });
    this.setStorage(storage);
  }
}

Now in the real browser application environment, we create an instance with a the browser's local storage API:

const localStorageModel = LocalStorageModel.create({ storage: window.localStorage });
localStorageModel.serializeAndSet({ key: 'browser', value: { really: 'works' } });

While in Node based tests, we can either not initialize the storage API at all in order to test methods which don't use it (assuming there are): const localStorageModel = LocalStorageModel.create({ storage: null }) - or - initialize it with a mock:

const localStorageModel = LocalStorageModel.create({ storage: { setItem: vi.fn() /* rest of Storage methods here */ } });
localStorageModel.serializeAndSet({ key: 'node', value: { really: 'works' } });
// Assertions here that the our model really works.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment