0

I want to create a generically typed function that returns a partial object of a type. In this case specifically one that assigns an ID parameter and returns a partial. Given a concrete type I can use a pattern like this without issue:

type Person = {
  id: string;
  name: string;
}

const generatePartialPerson = (): Partial<Person> => {
  const id = crypto.randomUUID() as string;
  const obj: Partial<Person> = { id };

  return obj;
}

In attempting to write a generic form that can accept any Type that has an {id: string} property, the solution I wrote encounters the type error:

Type '{ id: string; }' is not assignable to type 'Partial<T>'

const generatePartialGeneric = <T extends { id: string }>(
): Partial<T> => {
  const id = crypto.randomUUID() as string;
  const obj: Partial<T> = { id };

  return obj;
};

My understanding is that this is because a type that extends {id: string} may further constrain it, such as setting it to a fixed string literal, as in:

interface FixedPerson extends Person {
  id: "81a245c8-691e-471b-b07f-35e3ea3257ae";
}

Is there a way to write a generic constraint that would assert that the type/subtype must have an {id: string} property that hasn't been further constrained?

Ben Zittlau
  • 2,345
  • 1
  • 21
  • 30
  • That's not how the type system works, really... constraints are upper bounds and you are trying to enforce a partially lower bound. Instead, would something like [this](https://tsplay.dev/mbaVbN) suffice wherein you just replace any existing `id` property with `string`? If so I could write an answer explaining; if not, what am I missing? – jcalz Jul 21 '23 at 16:52
  • @jcalz That's interesting to know that that runs against the grain of the typing system. Are there any references to that from a design philosophy standpoint I could read? Just seems pretty limiting as (as I'm understanding it anyways) the pattern of mutating a property of an argument of a generic type wouldn't be supported. I'm surprised to see that this doesn't similarly complain: `const mutateGeneric = (obj: T ) => { obj.id = obj.id + "mutated"; }; ` – Ben Zittlau Jul 21 '23 at 17:25
  • Property writes to aliased subtypes are unsound in TS intentionally, see [this answer](https://stackoverflow.com/a/60922930/2887218), so there's almost always a way to do the wrong thing. The lack of lower bounds in constraints is a missing feature, see [ms/TS#14520](https://github.com/microsoft/TypeScript/issues/14520). I'm a bit concerned about expanding the scope of any answer here to everything involving generics, the type system, and soundness. How should we proceed for this question as asked? Does my suggestion meet your needs or should I be looking for a different approach? – jcalz Jul 21 '23 at 17:34
  • @jcalz Thanks for the references, and totally understand the resistance to go deeper here. I think your initial example meets my immediate needs, so if you want to write up an answer with that I would accept it. Based on what I've learned here I might need to rethink the overall approach though, so thanks again for the deeper context as well. – Ben Zittlau Jul 21 '23 at 18:58

1 Answers1

1

TypeScript's generic constraints don't currently support any kind of lower bounds, as requested in microsoft/TypeScript#14520. So you can say T extends X to mean that T has to be a subtype of X (so X is an upper bound of T), but you can't write T super X to mean that T has to be a supertype of X (so X is a lower bound of T). Lower bounds are useful when trying to safely represent property writes instead of reads.

If T super X existed in TypeScript, then you could maybe write your function like

declare const generatePartialGeneric: 
  <T extends {id: S}, S super string>() => Partial<T>;

but for now it's not part of the language.


Luckily you can simulate such a constraint with conditional types, as shown here:

const generatePartialGeneric =
  <T extends { id: string extends T["id"] ? unknown : never }>(
  ): Partial<T> => {
    const id: string = crypto.randomUUID();
    const obj = { id } as Partial<T>;
    return obj;
  };

This will end up comparing T against itself and will work if and only if it has an id property which is a supertype of string. Like this:

type Person = {
  id: string;
  name: string;
}
const x = generatePartialGeneric<Person>(); // okay

interface Oops {
  id: "oops"
  abc: string;
}
const y = generatePartialGeneric<Oops>(); // error

interface Okay {
  id: string | number
  abc: string;
}
const z = generatePartialGeneric<Okay>(); // okay

Note that in the implementation I had to use a type assertion at const obj = { id } as Partial<T> instead of annotating it like const obj: Partial<T> = { id }.

That's because the simulated lower bound is well beyond the compiler's ability to analyze inside the generic function implementation. The compiler really has no idea what might or might not be assignable to Partial<T>, so it complains if we try. And that complaint is the same error as from your original code. The reason the error exists is because of the possibility of string being too wide for the id property, as you mentioned and as described in Why can't I return a generic 'T' to satisfy a Partial<T>? ... but even if you specifically craft the function so that this possibility doesn't exist, the compiler just can't see it. It's too abstract.

So we use a type assertion and move on.


There are other approaches to the sort of thing you're doing; one is to not try to capture the id issue at all in T, and instead have generatePartialGeneric return a value whose type is related to T. We know that the return value will be of type {id: string} as well as something like Partial<Omit<T, "id">>. That is, no matter what T's id property is, the output will be of type string. That could be written like:

const generatePartialGeneric = <T extends object>() => {
  const id = crypto.randomUUID() as string;
  const obj: { [K in keyof T as Exclude<K, "id">]?: T[K] } = {};
  return Object.assign(obj, { id });
};

type Person = {
  id: string;
  name: string;
}

const p = generatePartialGeneric<Person>();
/* const p: { name?: string | undefined; } & { id: string; } */

const q: Partial<Person> = generatePartialGeneric<Person>();

There are no issues with constraints at all here, because we are not worried about what T might or might not be doing with id. You could call generatePartialGeneric<{a: number}> and you'd get a {a?: number; id: string} out.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360