2

I need a RxJS operator that can check that if a property on an object exists, and then change the type to correctly reflect this.

I already have an operator that will do this for an emitted value:

const notUndefined = <T>() =>
  filter<T | undefined, T>(
    (x): x is T extends undefined ? never : T => x !== undefined
  );

But I also need to be able to do it for one of its properties that can be used something like this:

interface MyInterface {
  myKey?: string;
}

of<MyInterface>({ myKey: "Some value" }).pipe(
  notUndefinedKey("myKey"),
  map((x) => x.myKey)
  //           ^? (property) myKey: string
)

of<MyInterface>({ myKey: "Some value" }).pipe(
  map((x) => x.myKey)
  //           ^? (property) MyInterface.myKey?: string | undefined
)

I've got a first working version, but it seems a bit excessive and seems to also remove methods from objects:

import { OperatorFunction, filter, of, map } from "rxjs";

// Typescript types helper
type DefinedFields<TType> = {
  [TKey in keyof TType]-?: DefinedFields<NonNullable<TType[TKey]>>;
};

type WithDefinedKey<TType, TKey extends keyof TType> = Omit<TType, TKey> & DefinedFields<Pick<TType, TKey>>;

type OperatorToDefinedKey<TType, TKey extends keyof TType> = OperatorFunction<
  TType | undefined,
  WithDefinedKey<TType, TKey>
>;

// Operator
export const notUndefinedKey = <TType, TKey extends keyof TType>(
  key: TKey
): OperatorToDefinedKey<TType, TKey> =>
  filter<TType>((x) => x?.[key] !== undefined) as OperatorToDefinedKey<
    TType,
    TKey
  >;

Is there a nice way to do this? One that maybe also keeps more of the original interface like methods on an object?

Playground Link

Mikkel R. Lund
  • 2,336
  • 1
  • 31
  • 44
  • 1
    [this question](https://stackoverflow.com/questions/69327990/how-can-i-make-one-property-non-optional-in-a-typescript-type) has some answeres that show how to declare a type that changes one properly of another type to be required. – BizzyBob Jun 27 '23 at 13:49

2 Answers2

2

You can simplify this function quite a lot as I don't think it does much for you to declare all those types. The function below returns a value that is the same type as what is passed and the value of the property key is definitely defined.

function notUndefinedKey<T, U extends keyof T>(key: U): OperatorFunction<T, T & { [K in U]-?: T[U] }> {
  return filter((x): x is T & { [K in U]-?: T[U] } => x[key] != null);
}

Example Usage

from([{}, { ignoreMe: 'Nope'}, {myKey: 'I am defined'}] as MyInterface[]).pipe(
  notUndefinedKey ('myKey'),
).subscribe(x => console.log(x.myKey));
// output: I am defined

Alternatives

If you really want the types defined (maybe because the syntax looks a bit confusing), you can do the following:

type NotUndefinedKeys<T, U extends keyof T> = T & { [K in U]-?: T[U] }

function notUndefinedKey<T, U extends keyof T>(key: U): OperatorFunction<T, NotUndefinedKeys<T, U>> {
  return filter((x): x is NotUndefinedKeys<T, U> => x[key] != null);
}

You could also have this work with multiple keys, which might be the most flexible usage and doesn't require much in the way of changes. Using the operator doesn't change at all - you can pass a single key or multiple keys. The operator change is very simple: accept a spread operator of types, change how the filter function works, and TypeScript works its magic.

function notUndefinedKeys<T, U extends keyof T>(...keys: U[]): OperatorFunction<T, T & { [K in U]-?: T[U] }> {
  return filter((x): x is T & { [K in U]-?: T[U] } => keys.every(k => x[k] != null));
}

StackBlitz

Daniel Gimenez
  • 18,530
  • 3
  • 50
  • 70
0

Just for reference, combining the different comments and answers, I've landed on this:

type NeverUndefined<T> = T extends undefined ? never : T;

type WithNeverUndefined<T, K extends keyof T> = T & {
  [P in K]-?: NeverUndefined<T[P]>;
};

export const notUndefinedKey = <T, K extends keyof T>(key: K):
  OperatorFunction<T | undefined, WithNeverUndefined<T, K>> =>
  filter((x): x is WithNeverUndefined<T, K> => x?.[key] !== undefined);

// And the multi-key version from @daniel-gimenez
export const notUndefinedKeys = <T, K extends keyof T>(...keys: [K, ...K[]]): 
  OperatorFunction<T | undefined, WithNeverUndefined<T, K>> =>
  filter((x): x is WithNeverUndefined<T, K> => keys.every(key => x?.[key] !== undefined));

It ensures that neither the object nor the property is undefined. It also works with optional and type unions with undefined:

interface MyInterface {
  myFirstKey?: string;
  mySecondKey: string | undefined;
  myThirdKey?: string | undefined;
}

of<MyInterface>(
  {
    myFirstKey: "Some value",
    mySecondKey: "Some value",
    myThirdKey: "Some value",
  }
).pipe(
  notUndefinedKeys("myFirstKey", "mySecondKey", "myThirdKey"),
  tap((x) => x.myFirstKey),
  //           ^? (property) MyInterface.myFirstKey: string
  tap((x) => x.mySecondKey),
  //           ^? (property) MyInterface.mySecondKey: string
  tap((x) => x.myThirdKey),
  //           ^? (property) MyInterface.myThirdKey: string
)

It also preserves methods and such on the object.

And finally, an updated version of notUndefined:

export const notUndefined = <T>(): OperatorFunction<T | undefined, NeverUndefined<T>> =>
  filter((x): x is NeverUndefined<T> => x !== undefined);

Playground

Mikkel R. Lund
  • 2,336
  • 1
  • 31
  • 44