TypeScript Partial<T> type without undefined
Asked Answered
M

2

34

How to create a kinda-Partial<T> type, that doesn't allow undefined values?

Here's an example:

interface MyType {
  foo: string
  bar?: number
}

const merge = (value1: MyType, value2: KindaPartial<MyType>): MyType => {
  return {...value1, ...value2};
}



const value = {
  foo: 'foo',
  bar: 42
}

merge(value, {});                          // should work
merge(value, { foo: 'bar' });              // should work
merge(value, { bar: undefined });          // should work
merge(value, { bar: 666 });                // should work
merge(value, { foo: '', bar: undefined }); // should work
merge(value, { foo: '', bar: 666 });       // should work

// now the problematic case:
merge(value, { foo: undefined }); // this should throw an error
                                  // because MyType["foo"] is of type string

The type I'm looking for should:

  • only accept keys that exist on the generic type (just like a normal Partial<T>)
  • accept a subset of the keys of the generic type
  • but don't accept undefined if the generic type doesn't accept undefined for that key

Is this possible?


EDIT: I also created a issue at TypeScript repository, because this is weird and should throw an error at some point: https://github.com/Microsoft/TypeScript/issues/29701

Microbiology answered 2/2, 2019 at 3:51 Comment(2)
Keep in mind that by doing this you may be making your code more difficult for programmatic use; you'd prohibit e.g. update(value: MyType, new_foo_val?: string) { return merge(value, { foo: new_foo_val }).Hockey
@Hockey That's exacty what should be prohibited. In your example, when new_foo_val is undefined, then merge will override value.foo with undefined, which will result in {...value, foo: undefined}, and that's not compatible with MyType, which specifies that foo is always a string.Microbiology
T
40

TS 4.4 UPDATE:

TS4.4 will have an --exactOptionalPropertyTypes compiler flag to give you the behavior you’re looking for directly with Partial, as long as you intentionally add undefined where you'd like to allow it:

interface MyType {
  foo: string
  bar?: number | undefined // <-- you want this
}

const merge = (value1: MyType, value2: Partial<MyType>): MyType => {
  return { ...value1, ...value2 };
}


const value = {
  foo: 'foo',
  bar: 42
}

merge(value, {});                          // okay
merge(value, { foo: 'bar' });              // okay
merge(value, { bar: undefined });          // okay
merge(value, { bar: 666 });                // okay
merge(value, { foo: '', bar: undefined }); // okay
merge(value, { foo: '', bar: 666 });       // okay

// now the problematic case:
merge(value, { foo: undefined }); // error!
// ----------> ~~~
// Type 'undefined' is not assignable to type 'string'

Playground link to code <-- note, currently you will need to turn on --exactOptionalPropertyTypes yourself in the TS Config tab; for some reason the url is broken

——--

PRE-TS4.4 ANSWER:

It is a known limitation (see microsoft/TypeScript#13195) that TypeScript doesn't properly distinguish between object properties (and function parameters) which are missing from ones with are present but undefined. The fact that Partial<T> allows undefined properties is a consequence of that. The right thing to do is to wait until this issue is addressed (this might become more likely if you go to that issue in GitHub and give it a 👍 or a comment with a compelling use case).

If you don't want to wait, you can maybe use the following hacky way to get something like this behavior:

type VerifyKindaPartial<T, KP> = 
  Partial<T> & {[K in keyof KP]-?: K extends keyof T ? T[K] : never}; 

const merge = <KP>(value1: MyType, value2: KP & VerifyKindaPartial<MyType, KP>): MyType => {
  return { ...value1, ...value2 };
}

So you can't write KindaPartial<T> directly. But you can write a type VerifyKindaPartial<T, KP> that takes a type T and a candidate type KP that you want to check against your intended KindaPartial<T>. If the candidate matches, then it returns something that matches KP. Otherwise it returns something that does not.

Then, you make merge() a generic function that infers KP from the type of the value passed into value2. If KP & VerifyKindaPartial<MyType, KP> matches KP (meaning that KP matches KindaPartial<MyType>), then the code will compile. Otherwise, if KP & VerifyKindaPartial<MyType, KP> does not match KP (meaning that KP does not match KindaPartial<MyType>), then there will be an error. (The error might not be very intuitive, though).

Let's see:

merge(value, {});                          // works
merge(value, { foo: 'bar' });              // works
merge(value, { bar: undefined });          // works
merge(value, { bar: 666 });                // works
merge(value, { foo: '', bar: undefined }); // works
merge(value, { foo: '', bar: 666 });       // works

merge(value, { foo: undefined }); // error!
//             ~~~ <-- undefined is not assignable to never
//  the expected type comes from property 'foo', 

That has the behavior you want... although the error you get is a bit weird (ideally it would say that undefined is not assignable to string, but the problem is that the compiler knows the passed-in type is undefined, and it wants the type to be string, so the compiler intersects these to undefined & string which is never. Oh well.

Anyway there are probably caveats here; generic functions work well when called directly but they don't compose well because TypeScript's support of higher-kinded types isn't that good. I don't know if this will actually work for your use case, but it's the best I can do with the language as it currently is.

Playground link to code

Tensiometer answered 2/2, 2019 at 20:53 Comment(4)
Thanks for the explanation! I gave thumbs up on the issue. I've used TypeScript and Partial<T> since it came out, but this is the first time I stumbled across this issue.Microbiology
Because it requires all the properties and isn't partial at all?Tensiometer
Nope, see documentation for mapped types. {[P in K]: X} has a property for each union member of K.Tensiometer
Your KindaMyType is the same type as MyType. The fact that you can (unsafely) use type assertions (you are calling this "casting") to narrow {bar: 666} to MyType isn't the same as the function accepting a partial MyType. I'm surprised you're not mentioning type assertions in your comments since that's the crux of your workaround.Tensiometer
H
13

In this case, Pick should work.

interface MyType {
  foo: string
  bar?: number
}

const merge = <K extends keyof MyType>(value1: MyType, value2: Pick<MyType, K>): MyType => {
  return {...value1, ...value2};
}

merge(value, {});                          // ok
merge(value, { foo: 'bar' });              // ok
merge(value, { bar: undefined });          // ok
merge(value, { bar: 666 });                // ok
merge(value, { foo: '', bar: undefined }); // ok
merge(value, { foo: '', bar: 666 });       // ok

merge(value, { foo: undefined });          // ng

Playground link

Handgrip answered 11/1, 2020 at 4:32 Comment(1)
This works just fine, although you lose intellisense supportAirwoman

© 2022 - 2024 — McMap. All rights reserved.