skip to Main Content

I have an event type in Typescript that looks like this:


export type EventRecord = {
    name: string;
    eta: string | null;
    assumed_time: string | null;
    indicated_time: string | null;
};

and function that displays the time of that event:

export const displayTime = (event: EventRecord): string | null =>
    event.indicated_time || event.assumed_time || event.eta;

What I want to achieve is to make sure that at least one of the three times (eta, assumed_time, indicated_time) is not null, so that when my event is Marked as done I’ll be able to display that time on the timeline component. I filter my events with Remeda to first, filter events tot only marked ones, and then displaying them:

const timelineEvents = R.pipe(
    events,
    R.filter(eventMarked),
    R.sortBy(displayTime),
    R.reverse
);

{timelineEvents.map((event: EventRecord) => {
    const time = new Date(displayTime(event)!);

    return (
            <TimelineEntry
                name={event.name}
                time={time}
            />
        </RecordContextProvider>
    );
})}

Here is my eventMarked function, it basically check if there is at least one time provided:

export const eventMarked = (event: eventRecord): boolean =>
    !!(event.assumed_time || event.indicated_time || event.eta);

The problem with this setup is that I keep getting errors:

S2345: Argument of type 'unknown[]' is not assignable to parameter of type '(input: EventRecord[]) => unknown'.
Type 'unknown[]' provides no match for the signature '(input: EventRecord[]): unknown'.
     80 |       events,
     81 |       R.filter(eventMarked),
   > 82 |       R.sortBy(displayTime),
        |       ^^^^^^^^^^^^^^^^^^^^^
     83 |       R.reverse
     84 |   );
 TS2769: No overload matches this call.
   Overload 1 of 2, '(array: readonly unknown[], ...sorts: SortRule<unknown>[]): unknown[]', gave the following error.
     Argument of type '(event: EventRecord) => string | null' is not assignable to parameter of type 'readonly unknown[]'.
   Overload 2 of 2, '(sort: SortRule<EventRecord>, ...sorts: SortRule<EventRecord>[]): (array: readonly EventRecord[]) => EventRecord[]', gave the following error.
     Argument of type '(event: EventRecord) => string | null' is not assignable to parameter of type 'SortRule<EventRecord>'.
       Type '(event: EventRecord) => string | null' is not assignable to type 'SortProjection<EventRecord>'.
         Type 'string | null' is not assignable to type 'Comparable'.
           Type 'null' is not assignable to type 'Comparable'.
     80 |       events,
     81 |       R.filter(eventMarked),
   > 82 |       R.sortBy(displayTime),
        |                ^^^^^^^^^^^
     83 |       R.reverse
     84 |   );
     85 |
TS2339: Property 'map' does not exist on type '(array: readonly unknown[]) => unknown[]'.
    114 |                           />
     115 |                      )}
   > 116 |                      {timelineEvents.map((event: EventRecord) => {
         |                                      ^^^

I think the cause of this error might be that typescript doesn’t really know what type is EventRecord because display is either null or string. How can this problem be solved?

2

Answers


  1. You can make a few changes in your types to help solve this.

    Acknowledging that all of the keys in EventRecord of type string | null are related (and are the ones that need to be checked in the runtime code for a best match), you can define them in an array using a const assertion and then use that to define your type along with an optional time property. Then you can define another type that has the time property as non-optional.

    That might look like this:

    TS Playground

    const eventRecordTimeKeys = ["indicated_time", "assumed_time", "eta"] as const;
    
    type TimeKey = typeof eventRecordTimeKeys[number];
    
    type EventRecord = Record<TimeKey, string | null> & {
      name: string;
      time?: Date;
    };
    
    /* EventRecord looks like this when expanded: {
      assumed_time: string | null;
      eta: string | null;
      indicated_time: string | null;
      name: string;
      time?: Date;
    } */
    
    type EventRecordWithTime = EventRecord & { time: Date };
    
    

    Then, you can convert your existing filter function to a type guard, which has a predicate as its return type. This will help the compiler understand that if this function returns true, then the object element definitely has a time property that’s a Date. In the version shared in the code below, I’ve written the function to first check if the expected property exists and is of the correct type. If not, the array from above is used to check each time key and — if one exists, the time property is set and the function returns true early. If all are falsy, then function returns false.

    I’ve also included a standard compare function to use with Array.prototype.sort() to sort by time (latest first):

    TS Playground

    function hasTime(event: EventRecord): event is EventRecordWithTime {
      if (event.time instanceof Date) return true;
    
      for (const key of eventRecordTimeKeys) {
        const value = event[key];
        if (value) {
          event.time = new Date(value);
          return true;
        }
      }
    
      return false;
    }
    
    function sortByLatestTime(a: { time: Date }, b: { time: Date }): number {
      return b.time.getTime() - a.time.getTime();
    }
    
    

    Putting that together in a reproducible example looks like this, and I’ve included the compiled JS below for you to run in a code snippet here:

    TS Playground

    const events: EventRecord[] = [
      { name: "a", assumed_time: null, eta: "2000-01-01T00:00:00.000Z", indicated_time: null },
      { name: "b", assumed_time: null, eta: null, indicated_time: null },
      { name: "c", assumed_time: null, eta: null, indicated_time: "2001-01-01T00:00:00.000Z" },
      { name: "d", assumed_time: null, eta: null, indicated_time: "2002-01-01T00:00:00.000Z" },
      { name: "e", assumed_time: "2003-01-01T00:00:00.000Z", eta: null, indicated_time: null },
    ];
    
    const timelineEvents = events
      .filter(hasTime)
      .sort(sortByLatestTime);
    
    timelineEvents.forEach(({ name, time }) => console.log(`${name}: ${time}`)); // Order: e d c a
    
    

    Compiled JS snippet:

    "use strict";
    const eventRecordTimeKeys = ["indicated_time", "assumed_time", "eta"];
    function hasTime(event) {
        if (event.time instanceof Date)
            return true;
        for (const key of eventRecordTimeKeys) {
            const value = event[key];
            if (value) {
                event.time = new Date(value);
                return true;
            }
        }
        return false;
    }
    function sortByLatestTime(a, b) {
        return b.time.getTime() - a.time.getTime();
    }
    const events = [
        { name: "a", assumed_time: null, eta: "2000-01-01T00:00:00.000Z", indicated_time: null },
        { name: "b", assumed_time: null, eta: null, indicated_time: null },
        { name: "c", assumed_time: null, eta: null, indicated_time: "2001-01-01T00:00:00.000Z" },
        { name: "d", assumed_time: null, eta: null, indicated_time: "2002-01-01T00:00:00.000Z" },
        { name: "e", assumed_time: "2003-01-01T00:00:00.000Z", eta: null, indicated_time: null },
    ];
    const timelineEvents = events
        .filter(hasTime)
        .sort(sortByLatestTime);
    timelineEvents.forEach(({ name, time }) => console.log(`${name}: ${time}`));

    When used with a React component, you shouldn’t have any trouble:

    TS Playground

    declare function TimelineEntry (props: { name: string; time: Date; }): ReactElement;
    
    function Component() {
      return timelineEvents.map(
        ({ name, time }) => <TimelineEntry key={name} name={name} time={time} />
      );
    }
    
    

    As far as the Ramda functional library is concerned: you don’t show what events is in your question, so I can’t reproduce your example. (Are you sure you’re using pipe correctly?) At any rate, Ramda isn’t strictly needed for this context, but you can adapt the TS techniques above to the Ramda functions if desired.

    Login or Signup to reply.
  2. The first step is to fix displayTime: the sortBy() overload you’re using accepts (x: T) => Comparable where Comparable is string | number | boolean and displayTime returns string | null.

    If one of those time properties is surely defined (it is because you filtered the array with eventMarked()) then change displayTime to:

    const displayTime = (event: EventRecord): string =>
        event.indicated_time || event.assumed_time || event.eta!;
    

    Note that if they cannot be an empty string (only null or a valid value) then you should also change || to ??.


    Optional: do you use these "marked" events often and you want to capture this constraint in the type definition?

    To fix this problem we may want to change the type to reflect what we want to enforce: at least one of those properties must not be null. To do it we first need to introduce a small type helper (heavily inspired from RequireAtLeastOne<T>). I’m sure you can write something better than this but take it as a starting point (check out also this post here on SO);

    type RequireAtLeastOneNotNull<T> = {
      [K in keyof T]: {
        [P in K]: NonNullable<T[P]>
      }
      & { [P in Exclude<keyof T, K>]: T[P] }
    } [keyof T];
    

    (I do not like the fact that you will still need ! in displayTime but I think it should be possible to help TS to infer string from event.indicated_time ?? event.assumed_time ?? event.eta.)

    For clarity you could separate those time properties:

    type EventTime = {
        eta: string | null;
        assumed_time: string | null;
        indicated_time: string | null;
    };
    
    type EventRecordWithTime = RequireAtLeastOneNotNull<EventTime> & {
        name: string;
    };
    

    That’s all:

    const valid: EventRecordWithTime = {
      name: "1",
      assumed_time: "2000/01/01",
      indicated_time: null,
      eta: null
    };
    
    // Error: indicated_time and eta are required
    const notValid1: EventRecordWithTime = {
      name: "1",
      assumed_time: "2000/01/01",
    };
    
    // Error: all required properties are missing
    const notValid2: EventRecordWithTime = {
      name: "1",
    };
    
    // Error: at least one of the "time" properties must not be null
    const notValid3: EventRecordWithTime = {
      name: "1",
      assumed_time: null,
      indicated_time: null,
      eta: null
    };
    

    If you use it often enough then you could change eventMarked() to be a type guard and return event is EventRecordWithTime. If it’s just for once then you can skip the above.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search