1

I am trying to format date strings into date objects recursively, but I'm getting an error:

type JsonBody<T> = T extends Date
  ? string
  : T extends (infer U)[]
  ? JsonBody<U>[]
  : T extends object
  ? { [P in keyof T]: JsonBody<T[P]> }
  : T;

type Person = {
  name: string;
  friend?: Person;
  createdAt: Date;
};

type PersonWithFriend = Omit<Person, "friend"> & Required<Pick<Person, "friend">>;

function formatPerson<T extends Person>(body: JsonBody<T>): T {
  return {
    ...body,
    friend: body.friend && formatPerson(body.friend),
    createdAt: new Date(body.createdAt)
  };
  // Type 'JsonBody<T> & { friend: Person | undefined; createdAt: Date; }' is not assignable to type 'T'.
  //  'JsonBody<T> & { friend: Person | undefined; createdAt: Date; }' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'Person'.
}

const one: JsonBody<Person> = { name: 'One', createdAt: '2021-01-21 00:59:11.07+00' };
const two: JsonBody<PersonWithFriend> = { name: 'One', friend: { name: 'Two', createdAt: '2021-01-21 00:59:11.07+00' }, createdAt: '2021-01-21 00:59:11.07+00' };
const oneFormatted = formatPerson(one).createdAt; // should be Date
const twoFormatted = formatPerson(two).friend.createdAt; // should be Date

See in Playground

Why does formatPerson(JsonBody<T extend Person>) not return T and why does T not become Person or PersonWithFriend depending on the passed argument?

Thank you for the help in advance.

mreaglejr
  • 81
  • 1
  • 7

1 Answers1

3

The problem is that the type parameters of generic functions are specified by the caller of the function, and that there are multiple ways to extend a type like Person. You can add new properties, but you can also narrow the types of existing properties. If the implementation cannot be verified by the compiler to conform to whatever the caller might specify, then there will be a compiler error inside the implementation.

For example:

interface SpecialDate extends Date {
  specialOccasion: string;
}
interface PersonWithSpecialDate extends Person {
  createdAt: SpecialDate;
}

const expectedPersonWithSpecialDate =
  formatPerson<PersonWithSpecialDate>({ name: "", createdAt: "" });

expectedPersonWithSpecialDate.createdAt.specialOccasion.toUpperCase(); // compiles, but 
// runtime error

Here the caller has, for some bizarre reason known only to themself, decided to ask formatPerson() for a PersonWithSpecialDate, whose createdAt property will itself have a specialOccasion property of type string. The call signature for formatPerson() claims to be able to do such a thing, since PersonWithSpeicalDate extends Person. And so the caller gets back something that has been claimed to be a PersonWithSpecialDate, and everything looks great until it explodes at runtime.

And so the warning inside the implementation is correct. The compiler is telling you that it cannot be sure that what you are returning will be usable as T. See Why can't I return a generic 'T' to satisfy a Partial<T>? for a more definitive Stack Overflow Q/A about this situation (since it comes from the TS team lead).


So what can you do?

Well the easiest thing to do here is just tell the compiler that you're not concerned about this possibility. Yes, technically T might be some weird thing that you can't return, but it's too unlikely for you to worry about. In this case, you can use a type assertion to say "trust me, this is a value of type T":

function formatPerson<T extends Person>(body: JsonBody<T>): T {
  return ({
    ...body,
    friend: body.friend && formatPerson(body.friend),
    createdAt: new Date(body.createdAt)
  }) as T; // no error now
}

There is surely a way to rewrite this so that the call signature is completely accurate (one will not be able to ask it for something it can't provide), yet the compiler may still not be able to verify that the implementation works to satisfy it. The compiler is not very adept at understanding assignability to conditional types depending on unspecified generics (See microsoft/TypeScript#33912 for the possibly canonical issue about this). So instead of going through the effort of trying to do this, I'll stop here. I may come back later and edit if I find something easy enough to write out that the compiler can actually verify.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360