TypeScript implementations of nullToUndefined() and undefinedToNull()
https://gist.github.com/tkrotoff/a6baf96eb6b61b445a9142e5555511a0
/* eslint-disable guard-for-in, @typescript-eslint/ban-types, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-assignment */
import { Primitive } from 'type-fest';
// ["I intend to stop using `null` in my JS code in favor of `undefined`"](https://github.com/sindresorhus/meta/discussions/7)
// [Proposal: NullToUndefined and UndefinedToNull](https://github.com/sindresorhus/type-fest/issues/603)
// Types implementation inspired by
// https://github.com/sindresorhus/type-fest/blob/v2.12.2/source/delimiter-cased-properties-deep.d.ts
// https://github.com/sindresorhus/type-fest/blob/v2.12.2/source/readonly-deep.d.ts
// https://gist.github.com/tkrotoff/a6baf96eb6b61b445a9142e5555511a0
export type NullToUndefined<T> = T extends null
  ? undefined
  : T extends Primitive | Function | Date | RegExp
  ? T
  : T extends Array<infer U>
  ? Array<NullToUndefined<U>>
  : T extends Map<infer K, infer V>
  ? Map<K, NullToUndefined<V>>
  : T extends Set<infer U>
  ? Set<NullToUndefined<U>>
  : T extends object
  ? { [K in keyof T]: NullToUndefined<T[K]> }
  : unknown;
export type UndefinedToNull<T> = T extends undefined
  ? null
  : T extends Primitive | Function | Date | RegExp
  ? T
  : T extends Array<infer U>
  ? Array<UndefinedToNull<U>>
  : T extends Map<infer K, infer V>
  ? Map<K, UndefinedToNull<V>>
  : T extends Set<infer U>
  ? Set<NullToUndefined<U>>
  : T extends object
  ? { [K in keyof T]: UndefinedToNull<T[K]> }
  : unknown;
function _nullToUndefined<T>(obj: T): NullToUndefined<T> {
  if (obj === null) {
    return undefined as any;
  }
  if (typeof obj === 'object') {
    if (obj instanceof Map) {
      obj.forEach((value, key) => obj.set(key, _nullToUndefined(value)));
    } else {
      for (const key in obj) {
        obj[key] = _nullToUndefined(obj[key]) as any;
      }
    }
  }
  return obj as any;
}
/**
 * Recursively converts all null values to undefined.
 *
 * @param obj object to convert
 * @returns a copy of the object with all its null values converted to undefined
 */
export function nullToUndefined<T>(obj: T) {
  return _nullToUndefined(structuredClone(obj));
}
function _undefinedToNull<T>(obj: T): UndefinedToNull<T> {
  if (obj === undefined) {
    return null as any;
  }
  if (typeof obj === 'object') {
    if (obj instanceof Map) {
      obj.forEach((value, key) => obj.set(key, _undefinedToNull(value)));
    } else {
      for (const key in obj) {
        obj[key] = _undefinedToNull(obj[key]) as any;
      }
    }
  }
  return obj as any;
}
/**
 * Recursively converts all undefined values to null.
 *
 * @param obj object to convert
 * @returns a copy of the object with all its undefined values converted to null
 */
export function undefinedToNull<T>(obj: T) {
  return _undefinedToNull(structuredClone(obj));
}
JS playground:
function _nullToUndefined(obj) {
    if (obj === null) {
        return undefined;
    }
    if (typeof obj === 'object') {
        if (obj instanceof Map) {
            obj.forEach((value, key) => obj.set(key, _nullToUndefined(value)));
        }
        else {
            for (const key in obj) {
                obj[key] = _nullToUndefined(obj[key]);
            }
        }
    }
    return obj;
}
function nullToUndefined(obj) {
    return _nullToUndefined(structuredClone(obj));
}
function _undefinedToNull(obj) {
    if (obj === undefined) {
        return null;
    }
    if (typeof obj === 'object') {
        if (obj instanceof Map) {
            obj.forEach((value, key) => obj.set(key, _undefinedToNull(value)));
        }
        else {
            for (const key in obj) {
                obj[key] = _undefinedToNull(obj[key]);
            }
        }
    }
    return obj;
}
function undefinedToNull(obj) {
    return _undefinedToNull(structuredClone(obj));
}
// Example with a simple object
const obj = {
  keyUndefined: undefined,
  keyNull: null,
  keyString: 'string'
};
const objWithUndefined = nullToUndefined(obj);
console.log(_.isEqual(objWithUndefined, {
  keyUndefined: undefined,
  keyNull: undefined,
  keyString: 'string'
}));
const objWithNull = undefinedToNull(objWithUndefined);
console.log(_.isEqual(objWithNull, {
  keyUndefined: null,
  keyNull: null,
  keyString: 'string'
}));
// Example with a complex object
const json = {
  keyUndefined: undefined,
  keyNull: null,
  keyString: 'string',
  array: [
    undefined,
    null,
    {
      keyUndefined: undefined,
      keyNull: null,
      keyString: 'string',
      array: [undefined, null, { keyUndefined: undefined, keyNull: null, keyString: 'string' }],
      object: { keyUndefined: undefined, keyNull: null, keyString: 'string' }
    }
  ],
  object: {
    keyUndefined: undefined,
    keyNull: null,
    keyString: 'string',
    array: [undefined, null, { keyUndefined: undefined, keyNull: null, keyString: 'string' }],
    object: { keyUndefined: undefined, keyNull: null, keyString: 'string' }
  }
};
const jsonWithUndefined = nullToUndefined(json);
console.log(_.isEqual(jsonWithUndefined, {
  keyUndefined: undefined,
  keyNull: undefined,
  keyString: 'string',
  array: [
    undefined,
    undefined,
    {
      keyUndefined: undefined,
      keyNull: undefined,
      keyString: 'string',
      array: [
        undefined,
        undefined,
        { keyUndefined: undefined, keyNull: undefined, keyString: 'string' }
      ],
      object: { keyUndefined: undefined, keyNull: undefined, keyString: 'string' }
    }
  ],
  object: {
    keyUndefined: undefined,
    keyNull: undefined,
    keyString: 'string',
    array: [
      undefined,
      undefined,
      { keyUndefined: undefined, keyNull: undefined, keyString: 'string' }
    ],
    object: { keyUndefined: undefined, keyNull: undefined, keyString: 'string' }
  }
}));
const jsonWithNull = undefinedToNull(jsonWithUndefined);
console.log(_.isEqual(jsonWithNull, {
  keyUndefined: null,
  keyNull: null,
  keyString: 'string',
  array: [
    null,
    null,
    {
      keyUndefined: null,
      keyNull: null,
      keyString: 'string',
      array: [null, null, { keyUndefined: null, keyNull: null, keyString: 'string' }],
      object: { keyUndefined: null, keyNull: null, keyString: 'string' }
    }
  ],
  object: {
    keyUndefined: null,
    keyNull: null,
    keyString: 'string',
    array: [null, null, { keyUndefined: null, keyNull: null, keyString: 'string' }],
    object: { keyUndefined: null, keyNull: null, keyString: 'string' }
  }
}));
<script src="https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.js"></script>
 
 
Unit tests:
/* eslint-disable @typescript-eslint/ban-types */
import { expectType } from 'tsd';
import { Opaque } from 'type-fest';
import { nullToUndefined, undefinedToNull } from './ObjectValues';
test('deep clone original value', () => {
  const obj = {
    keyUndefined: undefined,
    keyNull: null,
    keyString: 'string'
  };
  expect(nullToUndefined(obj)).not.toEqual(obj);
  expect(undefinedToNull(obj)).not.toEqual(obj);
});
test('object', () => {
  const obj = {
    keyUndefined: undefined,
    keyNull: null,
    keyString: 'string'
  };
  expectType<{ keyUndefined: undefined; keyNull: null; keyString: string }>(obj);
  const objWithUndefined = nullToUndefined(obj);
  expect(objWithUndefined).toEqual({
    keyUndefined: undefined,
    keyNull: undefined,
    keyString: 'string'
  });
  expectType<{ keyUndefined: undefined; keyNull: undefined; keyString: string }>(objWithUndefined);
  const objWithNull = undefinedToNull(objWithUndefined);
  expect(objWithNull).toEqual({
    keyUndefined: null,
    keyNull: null,
    keyString: 'string'
  });
  expectType<{ keyUndefined: null; keyNull: null; keyString: string }>(objWithNull);
});
test('array', () => {
  const arr = [undefined, null, 'string'];
  expectType<(undefined | null | string)[]>(arr);
  const arrWithUndefined = nullToUndefined(arr);
  expect(arrWithUndefined).toEqual([undefined, undefined, 'string']);
  expectType<(undefined | string)[]>(arrWithUndefined);
  const arrWithNull = undefinedToNull(arrWithUndefined);
  expect(arrWithNull).toEqual([null, null, 'string']);
  expectType<(null | string)[]>(arrWithNull);
});
test('function - not supported by structuredClone()', () => {
  function fn() {
    return 'Hello, World!';
  }
  expect(fn()).toEqual('Hello, World!');
  expectType<Function>(fn);
  // Won't throw if structuredClone() is not used
  expect(() => nullToUndefined(fn)).toThrow(
    /Uncloneable type: Function|function fn[\S\s]+could not be cloned\./
  );
  // Won't throw if structuredClone() is not used
  expect(() => undefinedToNull(fn)).toThrow(
    /Uncloneable type: Function|function fn[\S\s]+could not be cloned\./
  );
});
test('Date', () => {
  const date = new Date();
  const dateISO = date.toISOString();
  const dateWithUndefined = nullToUndefined(date);
  expect(dateWithUndefined.toISOString()).toEqual(dateISO);
  expectType<Date>(dateWithUndefined);
  const dateWithNull = undefinedToNull(date);
  expect(dateWithNull.toISOString()).toEqual(dateISO);
  expectType<Date>(dateWithNull);
});
test('RegExp', () => {
  const regex = /ab+c/;
  const regexWithUndefined = nullToUndefined(regex);
  expect(regexWithUndefined).toEqual(/ab+c/);
  expectType<RegExp>(regexWithUndefined);
  const regexWithNull = undefinedToNull(regex);
  expect(regexWithNull).toEqual(/ab+c/);
  expectType<RegExp>(regexWithNull);
});
test('Set - not supported', () => {
  // "The only way to "modify" a (primitive) item would be to remove it from the Set and then add the altered item."
  // https://stackoverflow.com/a/57986103
  const set = new Set([undefined, null, 'string']);
  expectType<Set<undefined | null | string>>(set);
  const setWithUndefined = nullToUndefined(set);
  expect([...setWithUndefined]).toEqual([undefined, null, 'string']);
  expectType<Set<undefined | null | string>>(setWithUndefined);
  const setWithNull = undefinedToNull(set);
  expect([...setWithNull]).toEqual([undefined, null, 'string']);
  expectType<Set<undefined | null | string>>(setWithNull);
});
test('Map', () => {
  const map = new Map([
    ['keyUndefined', undefined],
    ['keyNull', null],
    ['keyString', 'string']
  ]);
  expectType<Map<string, undefined | null | string>>(map);
  const mapWithUndefined = nullToUndefined(map);
  expect(Object.fromEntries(mapWithUndefined)).toEqual({
    keyUndefined: undefined,
    // FIXME https://github.com/facebook/jest/issues/13686
    //keyNull: undefined,
    keyNull: null,
    keyString: 'string'
  });
  expectType<Map<string, undefined | string>>(mapWithUndefined);
  const mapWithNull = undefinedToNull(map);
  expect(Object.fromEntries(mapWithNull)).toEqual({
    // FIXME https://github.com/facebook/jest/issues/13686
    //keyUndefined: null,
    keyUndefined: undefined,
    keyNull: null,
    keyString: 'string'
  });
  expectType<Map<string, null | string>>(mapWithNull);
});
test('Opaque type', () => {
  type UUID = Opaque<string, 'UUID'>;
  const uuid = '3a34ea98-651e-4253-92af-653373a20c51' as UUID;
  expectType<UUID>(uuid);
  const uuidWithUndefined = nullToUndefined(uuid);
  expect(uuidWithUndefined).toEqual('3a34ea98-651e-4253-92af-653373a20c51');
  expectType<UUID>(uuidWithUndefined);
  const uuidWithNull = undefinedToNull(uuid);
  expect(uuidWithNull).toEqual('3a34ea98-651e-4253-92af-653373a20c51');
  expectType<UUID>(uuidWithNull);
});
test('complex JSON', () => {
  const json = {
    keyUndefined: undefined,
    keyNull: null,
    keyString: 'string',
    array: [
      undefined,
      null,
      {
        keyUndefined: undefined,
        keyNull: null,
        keyString: 'string',
        array: [undefined, null, { keyUndefined: undefined, keyNull: null, keyString: 'string' }],
        object: { keyUndefined: undefined, keyNull: null, keyString: 'string' }
      }
    ],
    object: {
      keyUndefined: undefined,
      keyNull: null,
      keyString: 'string',
      array: [undefined, null, { keyUndefined: undefined, keyNull: null, keyString: 'string' }],
      object: { keyUndefined: undefined, keyNull: null, keyString: 'string' }
    }
  };
  expectType<{
    keyUndefined: undefined;
    keyNull: null;
    keyString: string;
    array: (
      | undefined
      | null
      | {
          keyUndefined: undefined;
          keyNull: null;
          keyString: string;
          array: (
            | undefined
            | null
            | { keyUndefined: undefined; keyNull: null; keyString: string }
          )[];
          object: { keyUndefined: undefined; keyNull: null; keyString: string };
        }
    )[];
    object: {
      keyUndefined: undefined;
      keyNull: null;
      keyString: string;
      array: (undefined | null | { keyUndefined: undefined; keyNull: null; keyString: string })[];
      object: { keyUndefined: undefined; keyNull: null; keyString: string };
    };
  }>(json);
  const jsonWithUndefined = nullToUndefined(json);
  expect(jsonWithUndefined).toEqual({
    keyUndefined: undefined,
    keyNull: undefined,
    keyString: 'string',
    array: [
      undefined,
      undefined,
      {
        keyUndefined: undefined,
        keyNull: undefined,
        keyString: 'string',
        array: [
          undefined,
          undefined,
          { keyUndefined: undefined, keyNull: undefined, keyString: 'string' }
        ],
        object: { keyUndefined: undefined, keyNull: undefined, keyString: 'string' }
      }
    ],
    object: {
      keyUndefined: undefined,
      keyNull: undefined,
      keyString: 'string',
      array: [
        undefined,
        undefined,
        { keyUndefined: undefined, keyNull: undefined, keyString: 'string' }
      ],
      object: { keyUndefined: undefined, keyNull: undefined, keyString: 'string' }
    }
  });
  expectType<{
    keyUndefined: undefined;
    keyNull: undefined;
    keyString: string;
    array: (
      | undefined
      | {
          keyUndefined: undefined;
          keyNull: undefined;
          keyString: string;
          array: (undefined | { keyUndefined: undefined; keyNull: undefined; keyString: string })[];
          object: { keyUndefined: undefined; keyNull: undefined; keyString: string };
        }
    )[];
    object: {
      keyUndefined: undefined;
      keyNull: undefined;
      keyString: string;
      array: (undefined | { keyUndefined: undefined; keyNull: undefined; keyString: string })[];
      object: { keyUndefined: undefined; keyNull: undefined; keyString: string };
    };
  }>(jsonWithUndefined);
  const jsonWithNull = undefinedToNull(jsonWithUndefined);
  expect(jsonWithNull).toEqual({
    keyUndefined: null,
    keyNull: null,
    keyString: 'string',
    array: [
      null,
      null,
      {
        keyUndefined: null,
        keyNull: null,
        keyString: 'string',
        array: [null, null, { keyUndefined: null, keyNull: null, keyString: 'string' }],
        object: { keyUndefined: null, keyNull: null, keyString: 'string' }
      }
    ],
    object: {
      keyUndefined: null,
      keyNull: null,
      keyString: 'string',
      array: [null, null, { keyUndefined: null, keyNull: null, keyString: 'string' }],
      object: { keyUndefined: null, keyNull: null, keyString: 'string' }
    }
  });
  expectType<{
    keyUndefined: null;
    keyNull: null;
    keyString: string;
    array: (null | {
      keyUndefined: null;
      keyNull: null;
      keyString: string;
      array: (null | { keyUndefined: null; keyNull: null; keyString: string })[];
      object: { keyUndefined: null; keyNull: null; keyString: string };
    })[];
    object: {
      keyUndefined: null;
      keyNull: null;
      keyString: string;
      array: (null | { keyUndefined: null; keyNull: null; keyString: string })[];
      object: { keyUndefined: null; keyNull: null; keyString: string };
    };
  }>(jsonWithNull);
});