What type signature should a removeUndefined method have?

Viewed 91

A common task in a JavaScript codebase involves removing undefined values from an object, as exemplified here:

function removeUndefined(myObj: any): any {
  const copyObj = { ...myObj };
  Object.keys(copyObj).forEach((key) => (copyObj[key] === undefined) && delete copyObj[key]);
  return copyObj;
}

Considering the following scenario:

interface QueryObject {
  id?: number;
  email?: string | null;
  createdAt?: Date | null;
}

async function runQuery(query: QueryObject) {
  const cleanQuery = removeUndefined(query); // type is 'any'
  await engine.run(cleanQuery);
}

In my case, I'm not able to turn on strictNullChecks, so as far as I can tell, the only way to stop the cleanQuery object from receiving something like { id: undefined } would be making its type equivalent to { id: number } | { id: number; email: string | null; } | .... Is this really the best course of action?

If so, what type signature for this function would achieve such return type for cleanQuery?

3 Answers

I made it for both active and disabled strictNullChecks options. Mostly, because I overlooked :D

strictNullChecks: true

type RemoveUndefined<T> = {
   [P in keyof T]: undefined extends T[P] ? never : null extends T[P] ? never : P
}[keyof T]

type Keys<T> = Array<keyof T>

const keys = <T,>(obj: T) => Object.keys(obj) as Keys<T>
const isFalsy = <T,>(elem: T) => elem === undefined || elem === null

const removeUndefined = <T,>(myObj: T) =>
   keys(myObj)
      .reduce((acc, elem) => isFalsy(elem) ? acc : { ...acc, [elem]: myObj[elem] }, {} as Pick<T, RemoveUndefined<T>> )

const result =  removeUndefined({ age: 'hello', name: undefined, surname: null }) // { age: 'hello' }

I'm not a fan of next solution:

function removeUndefined(myObj: any): any {
  return Object.keys(myObj).forEach((key) => (myObj[key] == null) && delete myObj[key]);
}

This function mutates the object and uses delete operator, this is no-no) My solutions , does not mutate anything

UPDATE

I replaced FilterTrueValues with Pick

UPDATE (I hope this is the final one :D)

I added IsAny utility, because I forgot that we should not be in strictNullChecks mode. Apologies for that. Thanks to @VLAZ for pointing this out!

Please keep in mind that if you have any type in your interface, you will get in trouble with my solution. I will figure out tomorrow how to deal with [any]

strictNullChecks: false

type IsAny<T> = 0 extends (1 & T) ? true : false;

type FilterAny<T> = {
   [P in keyof T]: IsAny<T[P]> extends true ? never : P
}[keyof T]

type RemoveUndefined<T> = {
   [P in keyof T]: T[P] extends undefined ? never : T[P] extends null ? never : P
}[keyof T]

type Keys<T> = Array<keyof T>

const keys = <T,>(obj: T) => Object.keys(obj) as Keys<T>
const isFalsy = <T,>(elem: T) => elem === undefined || elem === null

const removeUndefined = <T,>(myObj: T) =>
   keys(myObj)
      .reduce((acc, elem) => isFalsy(elem) ? acc : { ...acc, [elem]: myObj[elem] }, {} as Pick<T, RemoveUndefined<T> & FilterAny<T>> )

const result = removeUndefined({ age: 'hello', name: undefined, surname: null }) // { age: 'hello' }

The correct way to do it is to use the strictNullChecks compiler option which globally removes null and undefined from the domains of other types. However, if you cannot do it, you're a lot more limited in your options.

You might have to resort to using branded types. See TypeScript Deep Dive online book. Very simply, branding makes a new type that's incompatible with another by using an intersection &. So we can make

type MyBrand<T> = T & { _myBrand: "any value" };

interface Foo {
    id: number;
}

let obj: Foo = { id: 1 };
let brandedObj: MyBrand<Foo> = { id: 2, _myBrand: "any value" };

brandedObj = obj; // not allowed 

Playground Link

So, what we could do is produce our own branded type to signify that a given object has values. This will ensure that you can distinguish it from other objects that might have nullable values. Another concern is that we don't want anybody to be able to do brandedObj.existingValue = null. So, we can use a mapped type to do all of this:

const _hasValues = Symbol("has values");

type HasValues<T> = {
    readonly [P in keyof T]?: T[P];
} & {[_hasValues]: true};

This is a mapped branded type where:

  • the values are the same type as before.
  • the keys are the same as the original object but are all made optional. This is because we can remove some keys in the process. If the key is present, it's assumed to have a value that's non-nullish.
  • the properties are made readonly to avoid mutating them further.
  • the brand _hasValues is applied to distinguish it from other objects. I used a symbol here for the brand to avoid any potential clashes, but you can also use a plain property { _hasValues: true }.

You can complement that with a function that cleans up an object and applies the brand property. Here is an example of how this can be done either via mutating the input or by avoiding to mutate the input:

function inlineRemoveUndefined<T>(myObj: T): HasValues<T> {
    const keys = Object.keys(myObj) as (keyof T)[];

    for (const key of keys) {
        if (myObj[key] == null)
            delete myObj[key];
    }

    const brand = {[_hasValues]: true} as const;
    
    return Object.assign(myObj, brand) as HasValues<T>;
}

function cloneAndremoveUndefined<T>(myObj: T): Partial<HasValues<T>> {
    const existingKeyValuePairs = Object.entries(myObj)
        .filter(([,value]) => value != null);

    const brand = {[_hasValues]: true} as const;

    return Object.assign(Object.fromEntries(existingKeyValuePairs), brand) as Partial<HasValues<T>>;
}

Playground Link

At any rate, in your case the QueryObject already has optional keys, so the HasValues<QueryObject> is assignable to it:

//any implementation
declare function removeUndefined<T>(myObj: T): HasValues<T>;

interface QueryObject {
  id?: number;
  email?: string | null;
  createdAt?: Date | null;
}

async function runQuery(query: QueryObject) {
  const cleanQuery = removeUndefined(query);
  await engine.run(cleanQuery);
}

Playground Link


For the record, if you are able to use the strictNullTypes compiler option then the HasValues type can be simplified a lot:

//when using strictNullChecks compiler option

type HasValues<T> = {
   [P in keyof T]?: NonNullable<T[P]>;
}

Playground Link

  • by using the NonNullable utility type we change the types of all values to exclude null and undefined.
  • no need for readonly, as now you cannot assign illegal values anyway.
  • the keys are still optional because converting a {id: 1, email: null} to a HasValues will remove the email key. Any properties that are present must have a non-nullish value.

In this case, a HasValues<QueryObject> is still a subset of QueryObject and thus assignable to it.

Playground Link

Your function call will have a Partial type signature. considering the argument you passed is of type T.

removeUndefined(myObj: T): Partial<T>
Related