Typescript: create union instead intersection when merging optional with required type

心不动则不痛 提交于 2020-12-31 05:48:27

问题


When optional and required property are merged via intersection, required wins

type A = { who: string }
type B = { who?: string }
// $ExpectType {who:string}
type R = A & B

This may lead to runtime errors, when for instance, dealing with default params pattern within a function

type Params = {
  who: string
  greeting: string
}

const defaults: Params = {
  greeting: 'Hello',
  who: 'Johny 5',
}

function greeting(params: Partial<Params>){
  // $ExpectType Params
  const merged = {...defaults, ...params}

  return `${merged.greeting.toUpperCase()} ${merged.who} !`
}

// @throws - TypeError: Cannot read property 'toUpperCase' of undefined
greeting({greeting:undefined, who: 'Chuck'})

Question:

as what I described is how TS compiler behaves, question is, how to create mapped type, that would resolve that intersection to union

so something like:

type SafeMerge<A,B>=....

// $ExpectType {greeting?: string | undefined, who?: string | undefined }
type Result = SafeMerge<Params, Partial<Params>>

Example with mixed types:

// $ExpectType {one?: number | undefined, two: string, three: boolean }
type Result = SafeMerge<{one: number, two:string}, {one?: number, three: boolean}>

回答1:


Getting a merged type that is an amalgamation of two types with each property a union of possibilities is simple. We can just use a mapped type, over the keys of both constituent types:

type SafeMerge<T, U> = {
  [P in keyof T |  keyof U] : 
    | (T extends Partial<Record<P, any>> ? T[P] : never)
    | (U extends Partial<Record<P, any>> ? U[P] : never)
}
type Result = SafeMerge<{one: number, two:string }, {one?: number, three: boolean }>
// Result: 
// type Result = {
//     one: number | undefined;
//     two: string;
//     three: boolean;
// }

Play

The problem with the solution above is that we loose the optionality of the keys (also the readonly-ness, which is probably less of a concern for this use case). Homomorphic mapped types keep modifiers, but unfortunately we can't use one here since we don't really fit in any of the patterns for homomorphic mapped types ({ [P in keyof T] : T[P] } or {[P in K]: T[P] } where K is a type parameter with K extends keyof T, see Here and Here).

We can extract the optional keys and use two mapped types, one for any optional keys (keys that are optional in at least one of the constituents) and one for the required keys:

type OptionalPropertyOf<T> = Exclude<{
  [K in keyof T]: T extends Record<K, T[K]>
    ? never
    : K
}[keyof T], undefined>

type SafeMerge<T, U> = {
  [P in OptionalPropertyOf<T> |  OptionalPropertyOf<U>]?: 
    | (T extends Partial<Record<P, any>> ? T[P] : never)
    | (U extends Partial<Record<P, any>> ? U[P] : never)
} & {
  [P in Exclude<keyof T | keyof U,  OptionalPropertyOf<T> |  OptionalPropertyOf<U>>]: 
    | (T extends Partial<Record<P, any>> ? T[P] : never)
    | (U extends Partial<Record<P, any>> ? U[P] : never)
}

type Result = SafeMerge<{one: number, two:string, three: number}, {one?: number, three: boolean}>
// Result: 
// type Result = {
//     one?: number | undefined;
// } & {
//     two: string;
//     three: number | boolean;
// }

type Id<T> = {} & { [P in keyof T]: T[P] }
type FlattenedResult = Id<SafeMerge<{one: number, two:string, three: number }, {one?: number, three: boolean}>>
// type FlattenedResult = {
//     one?: number | undefined;
//     two: string;
//     three: number | boolean;
// }

Play

Optionally we can Id to flatten the intersection as I did in the example above, but that is optional.



来源:https://stackoverflow.com/questions/57474241/typescript-create-union-instead-intersection-when-merging-optional-with-require

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!