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