Supperhero
Supperhero

Reputation: 1191

How to define a type from an existing type but with optional keys (not with Partial)?

I'm doing some state merging in React/Redux along the way of:

const newStateSlice={
    ...state.activeData.entities[action.payload.localId],
    ...action.payload,
    isModified: true
}

My issue is that I want to define the type of action.payload so that it has the same exact type as a member of state.activeData.entities but with all props being optional (except localId) so that I can merge the keys I want to change and leave the rest unchanged.

Let's call this base type "Entity" and let's assume the following structure:

interface Entity{
    localId: number
    a: string
    b: number | undefined
    c?: Date
}

What I want to get is:

interface PartialEntity{
    localId: number
    a?: string
    b?: number | undefined
    c?: Date
}

I tried the following:

type PartialEntity = Partial<NewFeeds.State.ActiveFeed> & { localId: number }

The reason this doesn't work for me is that Partial allows the keys to have an undefined value (which I don't want to allow) so the resulting type is:

type PartialEntity{
    localId: number,
    a?: string | undefined,
    b?: number | undefined,
    c?: Date | undefined
}

I could, of course, manually write this out but I need to do this for every entity and probably again in the future and it's not very DRY... Is there some operator like Partial that does what I want?

EDIT: Turns out "c?: Date | undefined" and "c?: Date" are equivalent but what I really want is for typeScript to allow not setting the key but throw an error if it's set to undefined because undefined still overwrites my existing values when I spread an object like this:

const a = {prop_a: 1, prop_b: 2}
const b = {prop_a: undefined, prop_b: 3}
const c = {...a, ...b};

c is now:

{
    prop_a: undefined,
    prop_b: 3
}

Upvotes: 2

Views: 2397

Answers (1)

jcalz
jcalz

Reputation: 330401

Update for TS4.4+:

There is now an --exactOptionalPropertyTypes compiler option which will prevent you from writing undefined to an optional property (unless the value explicitly accepts undefined like your b). This compiler option is not part of the --strict suite of compiler features so it's not really considered "standard" the way the strict options are. If you enable it, it could cause other parts of your code to error, which may or may not be acceptable. If you do want to enable it, then it will solve your issue:

type Partialize<T, K extends keyof T> = Partial<Pick<T, K>> & Omit<T, K>
type PartialEntity = Partialize<Entity, "a" | "b" | "c">;

const noLocalId: PartialEntity = {}; // error
// Property 'localId' is missing in type

const definedAC: PartialEntity = { localId: 1, a: "hello", c: new Date() }; // okay

const undefinedA: PartialEntity = { localId: 1, a: undefined }; // error!
//     Types of property 'a' are incompatible.

const undefinedB: PartialEntity = { localId: 2, b: undefined }; // okay

const unexpectedD: PartialEntity = { localId: 4, d: "oops" } // error!
// Object literal may only specify known properties

If you don't want to enable it or are using TS4.3-, then see the original answer below.

Playground link to code


Original answer:

You've discovered that TypeScript doesn't do a perfect job of telling the difference between a property which is missing from one which is present but whose value is undefined. From the point of view of just reading one of these properties, there really is no difference. foo.bar === undefined will be true in either case. But of course, there are situations where there is a noticeable difference, such as when you spread an object, or even a simple test like "bar" in foo. This mismatch between the TypeScript type system and the behavior of Javascript is a longstanding open issue; see microsoft/TypeScript#13195 for a lengthy discussion. If you want to see this addressed, you could give it a 👍, but it's not clear to me if or when this will ever happen.

For the time being, there are workarounds.


The workaround I recommend here is to avoid the built-in optional property functionality, and instead make a generic helper function that, for the properties that would be optional, accepts a value without any such property, or a value with a non-undefined property. It looks something like this:

const strictPartial = <T, K extends keyof T>() =>
    <U extends {
        [P in keyof U]-?: P extends keyof T ? T[P] : never
    } & Omit<T, K>>(u: U) => u;

The strictPartial() function is a curried function. It takes a two type parameters: T, the object type in question (with no optional properties), and K, the union of keys you'd like to make "optional" in the way I described above. It then outputs a new function which only accepts objects of such a "strict partial" type. The return value of that function is the same as its input, so the compiler keeps track of which properties are actually present.

If you inspect the constraint on the U type parameter, you'll see that it is the intersection of Omit<T, K>, a type with all the properties of T not mentioned in K, and a mapped type where every property from U is either matched with its property from T, or is never. We will explore how that works below.


Here it is for the particular Entity type:

interface Entity {
    localId: number
    a: string
    b: number | undefined
    c: Date // <-- this is no longer optional, as mentioned above
}

const strictPartialEntity = strictPartial<Entity, "a" | "b" | "c">();
/* const strictPartialEntity: <U extends 
  { [P in keyof U]-?: P extends keyof Entity ? Entity[P] : never; } & 
  Omit<Entity, "a" | "b" | "c">>(u: U) => U */

So strictPartialEntity() is a function that takes an object of type U. This U type is like Entity, except that a, b, and c properties are now "optional". You must pass in a localId of type number (Omit<Entity, "a" | "b" | "c"> is equivalent to {localId: number}). You are allowed to leave out a, b, and c properties entirely (any key you leave out is not in keyof U, so [P in keyof U] will ignore it), or specify them as the right types (any key P you put in must match keyof Entity, and its value must match the same type, Entity[P]... or else it must match never which is impossible).

Note that only b will accept undefined, and that's because b is explicitly typed to allow it in Entity.

Let's make sure it behaves that way:

const noLocalId = strictPartialEntity({}); // error! 
// Property 'localId' is missing ---> ~~

const definedAC = strictPartialEntity(
    { localId: 1, a: "hello", c: new Date() }) // okay

const undefinedA = strictPartialEntity(
    { localId: 1, a: undefined }) // error! 
// -------------> ~
// Type 'undefined' is not assignable to type 'string'

const undefinedB = strictPartialEntity(
    { localId: 2, b: undefined }) // okay, 
// b explicitly allows undefined

const unexpectedD = strictPartialEntity(
    { localId: 4, d: "oops" }) // error!
// -------------> ~
//  Type 'string' is not assignable to type 'never'

const fullEntity: Entity = {
    ...definedAC,
    ...undefinedB
} // okay

That all behaves correctly. The compiler complains about undefined for a, but not for b. And the compiler recognizes that fullEntity is in fact an Entity, because it sees that definedAC and undefinedB together have all the properties of Entity.


As I said, this is a workaround to microsoft/TypeScript#13195. It's more complicated than we'd like because it struggles to express something that TypeScript doesn't make easy: that there is a difference between undefined and missing. The helper functions and generics are an unfortunate necessity if you want to try to do this.

The other way to approach this is to embrace TypeScript's inability to tell the difference, and defensively code against undefined. I'm not going to go into details here, since this is already quite a long answer. But, as a sketch: you could write a function like stripUndefined which iterates over an object's properties and removes any properties whose value is undefined. If you spread the output of that function, you don't have to worry anymore about a rogue undefined actually overwriting another property.

Playground link to code

Upvotes: 4

Related Questions