Last active
May 6, 2025 09:31
-
-
Save tkrotoff/a6baf96eb6b61b445a9142e5555511a0 to your computer and use it in GitHub Desktop.
Recursively converts all values from null to undefined and vice versa
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* 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)); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* 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