Skip to content

Instantly share code, notes, and snippets.

@tkrotoff
Last active May 6, 2025 09:31
Show Gist options
  • Save tkrotoff/a6baf96eb6b61b445a9142e5555511a0 to your computer and use it in GitHub Desktop.
Save tkrotoff/a6baf96eb6b61b445a9142e5555511a0 to your computer and use it in GitHub Desktop.
Recursively converts all values from null to undefined and vice versa
/* eslint-disable guard-for-in, @typescript-eslint/no-unsafe-return */
import { Primitive } from 'type-fest';
/*
* [Generic way to convert all instances of null to undefined in TypeScript](https://stackoverflow.com/q/50374869)
*
* This only works with JS objects hence the file name *Object*Values
*
* ["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
: // eslint-disable-next-line @typescript-eslint/ban-types
T extends Primitive | Function | Date | RegExp
? T
: T extends (infer U)[]
? 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
: // eslint-disable-next-line @typescript-eslint/ban-types
T extends Primitive | Function | Date | RegExp
? T
: T extends (infer U)[]
? 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) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
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
// Can cause: "Type instantiation is excessively deep and possibly infinite."
//extends Jsonifiable
>(obj: T) {
return _nullToUndefined(structuredClone(obj));
}
function _undefinedToNull<T>(obj: T): UndefinedToNull<T> {
if (obj === undefined) {
// eslint-disable-next-line unicorn/no-null
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) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
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
// Can cause: "Type instantiation is excessively deep and possibly infinite."
//extends Jsonifiable
>(obj: T) {
return _undefinedToNull(structuredClone(obj));
}
/* eslint-disable unicorn/no-null */
import { Opaque } from 'type-fest';
import { assertType, expect, test } from 'vitest';
import { NullToUndefined, nullToUndefined, UndefinedToNull, 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'
};
assertType<{ keyUndefined: undefined; keyNull: null; keyString: string }>(obj);
const objWithUndefined = nullToUndefined(obj);
expect(objWithUndefined).toEqual({
keyUndefined: undefined,
keyNull: undefined,
keyString: 'string'
});
assertType<{
keyUndefined: undefined;
keyNull: undefined;
keyString: string;
}>(objWithUndefined);
assertType<
NullToUndefined<{
keyUndefined: undefined;
keyNull: null;
keyString: string;
}>
>(objWithUndefined);
const objWithNull = undefinedToNull(objWithUndefined);
expect(objWithNull).toEqual({
keyUndefined: null,
keyNull: null,
keyString: 'string'
});
assertType<{ keyUndefined: null; keyNull: null; keyString: string }>(objWithNull);
assertType<
UndefinedToNull<{
keyUndefined: undefined;
keyNull: null;
keyString: string;
}>
>(objWithNull);
});
test('array', () => {
const arr = [undefined, null, 'string'];
assertType<(undefined | null | string)[]>(arr);
const arrWithUndefined = nullToUndefined(arr);
expect(arrWithUndefined).toEqual([undefined, undefined, 'string']);
assertType<(undefined | string)[]>(arrWithUndefined);
assertType<NullToUndefined<(undefined | null | string)[]>>(arrWithUndefined);
const arrWithNull = undefinedToNull(arrWithUndefined);
expect(arrWithNull).toEqual([null, null, 'string']);
assertType<(null | string)[]>(arrWithNull);
assertType<UndefinedToNull<(undefined | null | string)[]>>(arrWithNull);
});
test('function - not supported by structuredClone()', () => {
// eslint-disable-next-line unicorn/consistent-function-scoping
function fn() {
return 'Hello, World!';
}
expect(fn()).toBe('Hello, World!');
// eslint-disable-next-line @typescript-eslint/ban-types
assertType<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);
assertType<Date>(dateWithUndefined);
dateWithUndefined.setFullYear(1969);
expect(dateWithUndefined.getFullYear()).toBe(1969);
const dateWithNull = undefinedToNull(date);
expect(dateWithNull.toISOString()).toEqual(dateISO);
assertType<Date>(dateWithNull);
dateWithNull.setFullYear(1969);
expect(dateWithNull.getFullYear()).toBe(1969);
});
test('RegExp', () => {
const regex = /ab+c/;
const regexWithUndefined = nullToUndefined(regex);
expect(regexWithUndefined).toEqual(/ab+c/);
assertType<RegExp>(regexWithUndefined);
const regexWithNull = undefinedToNull(regex);
expect(regexWithNull).toEqual(/ab+c/);
assertType<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']);
assertType<Set<undefined | null | string>>(set);
const setWithUndefined = nullToUndefined(set);
expect([...setWithUndefined]).toEqual([undefined, null, 'string']);
assertType<Set<undefined | null | string>>(setWithUndefined);
const setWithNull = undefinedToNull(set);
expect([...setWithNull]).toEqual([undefined, null, 'string']);
assertType<Set<undefined | null | string>>(setWithNull);
});
test('Map', () => {
const map = new Map([
['keyUndefined', undefined],
['keyNull', null],
['keyString', 'string']
]);
assertType<Map<string, undefined | null | string>>(map);
const mapWithUndefined = nullToUndefined(map);
expect(Object.fromEntries(mapWithUndefined)).toEqual({
keyUndefined: undefined,
keyNull: undefined,
keyString: 'string'
});
assertType<Map<string, undefined | string>>(mapWithUndefined);
assertType<NullToUndefined<Map<string, undefined | null | string>>>(mapWithUndefined);
const mapWithNull = undefinedToNull(map);
expect(Object.fromEntries(mapWithNull)).toEqual({
keyUndefined: null,
keyNull: null,
keyString: 'string'
});
assertType<Map<string, null | string>>(mapWithNull);
assertType<UndefinedToNull<Map<string, undefined | null | string>>>(mapWithNull);
});
test('Symbol - not supported', () => {
const symbol = Symbol('description');
expect(symbol.description).toBe('description');
expect(symbol.toString()).toBe('Symbol(description)');
expect(() => nullToUndefined(symbol)).toThrow('Symbol(description) could not be cloned');
expect(() => undefinedToNull(symbol)).toThrow('Symbol(description) could not be cloned');
});
test('class - not supported', () => {
class Superclass {
name = 'Superclass';
getName() {
return this.name;
}
keyNull = null;
object = { keyNull: null };
}
class Subclass extends Superclass {
name = 'Subclass';
}
const subclass = new Subclass();
expect(subclass.name).toBe('Subclass');
expect(subclass.getName()).toBe('Subclass');
expect(subclass.object).toEqual({ keyNull: null });
expect(subclass.keyNull).toBeNull();
expect(JSON.stringify(subclass)).toBe(
'{"name":"Subclass","keyNull":null,"object":{"keyNull":null}}'
);
const subclassWithUndefined = nullToUndefined(subclass);
expect(subclassWithUndefined.name).toBe('Subclass');
expect(() => subclassWithUndefined.getName()).toThrow('getName is not a function');
expect(subclassWithUndefined.object).toEqual({ keyNull: undefined });
expect(subclassWithUndefined.keyNull).toBeUndefined();
expect(JSON.stringify(subclassWithUndefined)).toBe('{"name":"Subclass","object":{}}');
const subclassWithNull = undefinedToNull(subclass);
expect(subclassWithNull.name).toBe('Subclass');
expect(() => subclassWithNull.getName()).toThrow('getName is not a function');
expect(subclassWithNull.object).toEqual({ keyNull: null });
expect(subclassWithNull.keyNull).toBeNull();
expect(JSON.stringify(subclassWithNull)).toBe(
'{"name":"Subclass","keyNull":null,"object":{"keyNull":null}}'
);
});
test('Error - not supported', () => {
class MyError extends Error {
keyNull = null;
object = { keyNull: null };
}
const error = new MyError('message');
expect(error.name).toBe('Error');
expect(error.message).toBe('message');
expect(error.keyNull).toBeNull();
expect(error.object).toEqual({ keyNull: null });
// eslint-disable-next-line @typescript-eslint/no-base-to-string
expect(error.toString()).toBe('Error: message');
expect(JSON.stringify(error)).toBe('{"keyNull":null,"object":{"keyNull":null}}');
const errorWithUndefined = nullToUndefined(error);
expect(errorWithUndefined.name).toBe('Error');
expect(errorWithUndefined.message).toBe('message');
expect(errorWithUndefined.keyNull).toBeUndefined();
expect(errorWithUndefined.object).toBeUndefined();
// eslint-disable-next-line @typescript-eslint/no-base-to-string
expect(errorWithUndefined.toString()).toBe('Error: message');
expect(JSON.stringify(errorWithUndefined)).toBe('{}');
const errorWithNull = undefinedToNull(error);
expect(errorWithNull.name).toBe('Error');
expect(errorWithNull.message).toBe('message');
expect(errorWithNull.keyNull).toBeUndefined();
expect(errorWithNull.object).toBeUndefined();
// eslint-disable-next-line @typescript-eslint/no-base-to-string
expect(errorWithNull.toString()).toBe('Error: message');
expect(JSON.stringify(errorWithNull)).toBe('{}');
});
test('Headers - not supported', () => {
const headers = new Headers({
keyString: 'string'
});
expect(Object.fromEntries(headers)).toEqual({
keystring: 'string'
});
expect(headers.get('keyString')).toBe('string');
expect(() => nullToUndefined(headers)).toThrow('Cannot clone object of unsupported type');
expect(() => undefinedToNull(headers)).toThrow('Cannot clone object of unsupported type');
});
test('Opaque type', () => {
type UUID = Opaque<string, 'UUID'>;
const uuid = '3a34ea98-651e-4253-92af-653373a20c51' as UUID;
assertType<UUID>(uuid);
const uuidWithUndefined = nullToUndefined(uuid);
expect(uuidWithUndefined).toBe('3a34ea98-651e-4253-92af-653373a20c51');
assertType<UUID>(uuidWithUndefined);
const uuidWithNull = undefinedToNull(uuid);
expect(uuidWithNull).toBe('3a34ea98-651e-4253-92af-653373a20c51');
assertType<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' }
}
};
assertType<{
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'
}
}
});
assertType<{
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' }
}
});
assertType<{
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);
});
test('Beatles', () => {
type Biography = {
firstName: string;
lastName: string;
born: string;
died: string | null;
};
type Person = {
title: string;
description: string;
biography: Biography;
};
// As of 2023-06-16, anyway they are immortals
const beatles: Person[] = [
{
title: 'John Lennon',
description: 'English singer-songwriter; founding member of the Beatles',
biography: {
firstName: 'John',
lastName: 'Lennon',
born: '1940-10-09',
died: '1980-12-08'
}
},
{
title: 'Paul McCartney',
description: 'English musician, member of the Beatles',
biography: {
firstName: 'Paul',
lastName: 'McCartney',
born: '1942-06-18',
died: null
}
},
{
title: 'George Harrison - Wikipedia',
description: 'English musician and singer-songwriter',
biography: {
firstName: 'George',
lastName: 'Harrison',
born: '1943-02-25',
died: '2001-11-29'
}
},
{
title: 'Ringo Starr',
description: 'English musician, drummer for the Beatles',
biography: {
firstName: 'Ringo',
lastName: 'Starr',
born: '1940-07-07',
died: null
}
}
];
const beatlesWithUndefined = nullToUndefined(beatles);
expect(beatlesWithUndefined).toEqual([
{
title: 'John Lennon',
description: 'English singer-songwriter; founding member of the Beatles',
biography: {
firstName: 'John',
lastName: 'Lennon',
born: '1940-10-09',
died: '1980-12-08'
}
},
{
title: 'Paul McCartney',
description: 'English musician, member of the Beatles',
biography: {
firstName: 'Paul',
lastName: 'McCartney',
born: '1942-06-18',
died: undefined
}
},
{
title: 'George Harrison - Wikipedia',
description: 'English musician and singer-songwriter',
biography: {
firstName: 'George',
lastName: 'Harrison',
born: '1943-02-25',
died: '2001-11-29'
}
},
{
title: 'Ringo Starr',
description: 'English musician, drummer for the Beatles',
biography: {
firstName: 'Ringo',
lastName: 'Starr',
born: '1940-07-07',
died: undefined
}
}
]);
assertType<
{
title: string;
description: string;
biography: {
firstName: string;
lastName: string;
born: string;
died: string | undefined;
};
}[]
>(beatlesWithUndefined);
const beatlesWithNull = undefinedToNull(beatlesWithUndefined);
expect(beatlesWithNull).toEqual([
{
title: 'John Lennon',
description: 'English singer-songwriter; founding member of the Beatles',
biography: {
firstName: 'John',
lastName: 'Lennon',
born: '1940-10-09',
died: '1980-12-08'
}
},
{
title: 'Paul McCartney',
description: 'English musician, member of the Beatles',
biography: {
firstName: 'Paul',
lastName: 'McCartney',
born: '1942-06-18',
died: null
}
},
{
title: 'George Harrison - Wikipedia',
description: 'English musician and singer-songwriter',
biography: {
firstName: 'George',
lastName: 'Harrison',
born: '1943-02-25',
died: '2001-11-29'
}
},
{
title: 'Ringo Starr',
description: 'English musician, drummer for the Beatles',
biography: {
firstName: 'Ringo',
lastName: 'Starr',
born: '1940-07-07',
died: null
}
}
]);
assertType<
{
title: string;
description: string;
biography: {
firstName: string;
lastName: string;
born: string;
died: string | null;
};
}[]
>(beatlesWithNull);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment