1

We generate typescript interfaces for every API endpoint - one represents the JSON and another represents the "parsed" object. Dates are printed as strings in JSON so we have a global function that takes that raw data, an array listing which fields are dates, and it converts each field into a Date so it's easier to use in the javascript.

My problem is that I don't know how to properly type this function so that any "xyzJson" interface (aka SequenceJson) can be passed in, and the "parsed" interface is returned (aka Sequence)

I assume I'll need generics (something like <SequenceJson, Sequence>) but typescript won't let me return the new object based on the old because they don't have "enough in common".

Is there a better approach?

// This describes the shape of the raw JSON from this api call
export interface SequenceJson {
  rowId: string
  sequenceId: number
  sequenceTimestamp: string // <-- this is string
}

// This describes the shape our query code returns (Dates have been parsed)
export interface Sequence {
  rowId: string
  sequenceId: number
  sequenceTimestamp: Date // <-- this is now Date
}


// Here's the function for converting the "___Json" object to the one with `Date`s
const parseRecordDates = (record, fields: string[]) => {
  const result = { ...record }

  for (const field of fields) {
    if (field in record) {
      const value = record[field]

      if (value !== null) {
        result[field] = new Date(value)
      }
    }
  }

  return record
}

// Example call parseRecordDates(json, ['sequenceTimestamp'])

The function should accept "SequenceJson" and return "Sequence"

helion3
  • 34,737
  • 15
  • 57
  • 100
  • Does [this approach](https://tsplay.dev/wR727N) meet your needs? If so I'll write up an answer explaining; if not, what am I missing? – jcalz Jun 13 '23 at 17:53
  • @jcalz I need to try it but after looking it over, it looks like what I was after. Our linter requires that we explicitly define return types for functions, which is a bigger part of my issue here than I realized. What would you define as the return type for `parseRecordDates` given what you wrote? I also need to digest that KeysMatching type, that's some fancy ts! – helion3 Jun 13 '23 at 18:09
  • @helion3 • You could just do [this](https://tsplay.dev/mqxdqN), which is just copying the returned value type to the return type annotation. • I'm interpreting "I need to try it" as "I don't yet know if that approach meets my needs" so I will wait to hear more before I consider writing up a detailed explanation (in case I'm explaining something that doesn't actually work for you). Please let me know when and how to proceed. – jcalz Jun 13 '23 at 18:56
  • Yes, this does what I needed. I do need to make a map version of this for arrays of records but this is doing what I needed help with. It's really just a convenience method that uses this core function on an array instead: `const parseRecordsDates = (records, fields) => records.map(record => parseRecordDates(record, fields))` – helion3 Jun 13 '23 at 20:16

1 Answers1

1

You want parseRecordDates() to be generic both in the type T of the record parameter, and the type K corresponding to just those keys of T present in the fields parameter. And we should constrain T or K or both so that the property type of T at the keys K are known to hold string properties.

The return type will be { [P in keyof T]: P extends K ? Date : T[P] }. That's a mapped type where each key P from the keys of T will be a key of the return type, and where each property is a conditional type that checks if P is included in the fields element type K: if so, then the property will be of the Date type, otherwise it is unchanged (and stays T[P], the indexed access type corresponding to the property value of T and the index P).

Here's one way to write it:

const parseRecordDates = <
    T extends Record<K, string>,
    K extends keyof T
>(
    record: T, fields: K[]
): { [P in keyof T]: P extends K ? Date : T[P] } => {
    const result = { ...record } as any
    for (const field of fields) {
        const value = record[field]
        result[field] = new Date(value)
    }       
    return result;
}

Here I've constrained T so that it is known to have string properties at the K keys.

I used the any type to loosen the type checking on result and avoid compiler errors. In general, we can't count on the TypeScript compiler to verify that a function implementation properly returns a generic mapped conditional type. It will either be too lenient and allow bad implementations, or too strict and complain about correct implementations. I'm just sidestepping this by using any. There are other approaches, but no matter what, you want to be careful inside the function implementation.


That implementation should work as desired:

const json: SequenceJson =
    { sequenceId: 123, rowId: "abc", sequenceTimestamp: new Date().toString() };
const seq = parseRecordDates(json, ['sequenceTimestamp']);
/* const seq: {
    rowId: string;
    sequenceId: number;
    sequenceTimestamp: Date;
} */
console.log(seq.sequenceTimestamp.getFullYear()) // 2023
function acceptSequence(x: Sequence) { }
acceptSequence(seq) // okay

It also produces an error when you call it incorrectly:

parseRecordDates(json, ['sequenceId']); // error
// ------------> ~~~~
// Argument of type 'SequenceJson' is not assignable
// to parameter of type 'Record<"sequenceId", string>'.

although the location of the error might be surprising, as it complains about json and not "sequenceId".


If you care about the location of that error, you can augment the call signature of parseRecordDates() to constrain K further than just keyof T, to just those keys of T whose property value types are assignable to string. We can call that KeysMatching<T, string>... but for now anyway we need to implement KeysMatching ourselves. One implementation of KeysMatching is

type KeysMatching<T, V> =
    keyof { [K in keyof T as T[K] extends V ? K : never]: any }

(See In TypeScript, how to get the keys of an object type whose values are of a given type? for more information about various implementations). And then we change the constraint on K:

const parseRecordDates = <
    T extends Record<K, string>,
    K extends KeysMatching<T, string> // <-- change is here
>(
    record: T, fields: K[]
): { [P in keyof T]: P extends K ? Date : T[P] } => {
    const result = { ...record } as any
    for (const field of fields) {
        const value = record[field]
        result[field] = new Date(value)
    }
    return result;
}

And now we see the error in a more expected place.

parseRecordDates(json, ['sequenceId']); // error
// -------------------> ~~~~~~~~~~~~
// Type '"sequenceId"' is not assignable to type '"sequenceTimestamp" | "rowId"'.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Do you have advice on how to type a map function for this? For APIs that return multiple records we return an array, so I need to call `parseRecordDates` on multiple `records`. I've tried adapting the signature for an array of records, but because you have lookups, the T needs to represent the base type not the array form, so I'm unsure how to declare that instead. – helion3 Jun 13 '23 at 22:33
  • Without a [mre] it's hard to say; maybe [this](https://tsplay.dev/wO577m) is what you're looking for. If not, or if you need to discuss further, you might want to open a new question post for it – jcalz Jun 13 '23 at 22:42
  • Yes that's exactly what I meant. Although your example is almost *exactly what I wrote, but had mis-typed the field name and ran into the problem you described of the error not being in the right place, so it confused me. `const parseRecordsDates = >( records: T[], fields: K[] ): { [P in keyof T]: P extends K ? Date : T[P] }[] => records.map(record => parseRecordDates(record, fields))` – helion3 Jun 13 '23 at 23:07