I have been developing a versatile function that allows for adding fields one by one to partial objects of an unknown type in a strongly typed and safe manner. To illustrate, consider the following scenario:
type Example = { x: string, y: number, z: boolean } // Object type to be constructed
type MissingFields = 'x' | 'y' // Fields missing from the partially complete object
type FieldToAdd = 'y' // Field to add to the partially complete object
const partialObject: Omit<Example, MissingFields> = { z: true } // Current object
const fieldToAdd: Pick<Example, FieldToAdd> = { y: 2 } // Extend with...
const updatedResult: Omit<Example, Exclude<MissingFields, FieldToAdd>> = { ...partialObject, ...fieldToAdd } // Result
The above example showcases a smooth integration without any typing issues. The proposed function signature would appear as follows:
function add<
ObjectType extends object,
AddedField extends MissingFields,
MissingFields extends keyof ObjectType = keyof ObjectType> (
currentPartial: Omit<ObjectType, MissingFields>,
fieldToAdd: AddedField,
fieldValue: ObjectType[AddedField]
): Omit<ObjectType, Exclude<MissingFields, AddedField>> {
// code goes here
}
const updatedObject: Pick<Example, 'y' | 'z'> = add(partialObject, 'y', 2)
While there are no inherent typing discrepancies apart from the absence of implementation, resolving this issue seems straightforward:
return { ...currentPartial, [fieldToAdd]: fieldValue }
, although this approach encounters an error because:
const fieldToUpdate: Pick<ObjectType, AddedField> = { [fieldToAdd]: fieldValue } // Type '{ [x: string]: ObjectType[AddedField]; }' cannot be assigned to type 'Pick<ObjectType, AddedField>'.(2322)
This known obstacle arises due to narrowing (https://github.com/microsoft/TypeScript/issues/13948) - a cast may resolve it... unfortunately not:
const fieldToUpdate = { [fieldToAdd]: fieldValue } as Pick<ObjectType, AddedField>
const updatedResult: Omit<ObjectType, Exclude<MissingFields, FieldToAdd>> = { ...currentPartial, ...fieldToUpdate }
// Type 'Omit<ObjectType, MissingFields> & Pick<ObjectType, FieldToAdd>' cannot be assigned to type 'Omit<ObjectType, Exclude<MissingFields, FieldToAdd>>'.(2322)
This issue seems to stem from TypeScript incorrectly associating the type
Omit<ObjectType, MissingFields> & Pick<ObjectType, FieldToAdd>
on the right side of the assignment, failing to narrow adequately. The discrepancy possibly occurs when MissingFields
and FieldToAdd
overlap, resulting in something like { ...{ x: string }, ...{ x: number } }
becoming { x: number }
rather than { x: string & number }
, which is actually { x: never }
according to TypeScript.
To steer clear of casts and already having utilized one due to the aforementioned issue, additional adjustments seem necessary, but it gets more convoluted...
const finalUpdatedResult = { ...currentPartial, ...fieldToUpdate } as Omit<ObjectType, Exclude<MissingFields, FieldToAdd>>
// Converting type 'Omit<ObjectType, MissingFields> & Pick<ObjectType, FieldToAdd>' to type 'Omit<ObjectType, Exclude<MissingFields, FieldToAdd>>' could potentially be erroneous as neither type sufficiently overlaps with the other. If intentional, convert the expression to 'unknown' first.(2352)
My query revolves around whether my evaluation is flawed and whether I am incorrect in expecting that
{ ...Omit<ObjectType, MissingFields>, ...Pick<ObjectType, FieldToAdd> }
should consistently result in Omit<ObjectType, Exclude<MissingFields, FieldToAdd>></code given that <code>ObjectType
is confined to being an object
?
TL;DR
In my practical application, the function's intricacy is elevated - the partial object not only lacks certain fields but can also contain them erroneously, as indicated in the concrete example below:
type Example = { a: string, b: number, c: boolean }
type MissingValues = 'a' | 'b'
type FieldToExtend = 'b'
const incompleteTempObject: Omit<Example, MissingValues> & OptionalRecord<MissingValues, unknown> = { b: null, c: true }
const fieldExtension: Pick<Example, FieldToExtend> = { b: 2 }
const updatedFinalResult: Omit<Example, Exclude<MissingValues, FieldToExtend>> & OptionalRecord<Exclude<MissingValues, FieldToExtend>, unknown> = { ...incompleteTempObject, ...fieldExtension }
where
type OptionalRecord<K extends string | number | symbol, V> = { [key in K]+?: V }
With this extended version, the function's signature takes the following shape:
function add<
ObjectType extends object,
FieldToExtend extends MissingValues,
MissingValues extends keyof ObjectType = keyof ObjectType>(
initialPartial: Omit<ObjectType, MissingValues> & OptionalRecord<MissingValues, unknown>,
fieldUpdated: FieldToExtend,
updatedFieldValue: ObjectType[FieldToExtend]
): Omit<ObjectType, Exclude<MissingValues, FieldToExtend>> & OptionalRecord<Exclude<MissingValues, FieldToExtend>, unknown> {
// code implemented here
}
Despite encountering a similar issue, this modification worked smoothly without requiring 'as unknown as whatever
':
const fieldToUpdate = { [fieldAdded]: addedFieldValue } as Pick<ObjectType, FieldToExtend>
const finalUpdatedResult = { ...initialPartial, ...fieldToUpdate }
as { [keys in keyof ObjectType]: keys extends MissingValues ? keys extends FieldToExtend ? ObjectType[keys] : never : ObjectType[keys] }
as Omit<ObjectType, Exclude<MissingValues, FieldToExtend>> & OptionalRecord<Exclude<MissingValues, FieldToExtend>, unknown>
No complaints concerning type incompatibility arose, showcasing a successful resolution.