I'm revisiting an issue after a year of being stumped by it. I have no idea how to frame this question concisely so please bare with me.
ISSUE: I want to narrow the union type of a nested object by the value of its kind
property. However, the kind
field is optional because it should be "string"
by default.
The TField
type (simplified):
type TField = {
field: string;
name: string;
placeholder?: string;
required?: boolean;
}& (
| { kind: "string"; value?: string; block?: boolean }
| { kind: "password"; value?: string }
| { kind: "email"; value?: string }
| { kind: "number"; value?: number }
| { kind: "boolean"; value?: boolean; inline?: boolean; toggle?: boolean }
)
// ----- EDIT: I want the "name","field","kind" to be optional
// ----------- By default: "kind" should be 'string'
type PartialBy<T,K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
type TLabeledField = PartialBy<TField, "name"|"field"|"kind">
type TFieldCluster = {
[key: string]: TLabeledField;
};
function makeFields(cluster: TFieldCluster){
const fieldMap = Object.entries(cluster).map(([str, path]) => {
let label = str;
let name,field;
let required = false;
if (label.endsWith("*")) {
required = true
label = label.slice(0,-1)
}
if (label.startsWith("$")){
field = label.slice(1);
name = label.replace(/([A-Z])/g, " $1").toLowerCase();
} else {
field = label // <-- will fix
name = label
}
return [
field,
{
type: "string",
name,
field,
required,
...path,
},
];
});
return Object.fromEntries(fieldMap);
}
const fields = makeFields({
title: {},
subtitle: {kind:"boolean", toggle: true}, // <-- SHOULD BE VALID BECAUSE I SPECIFIED 'KIND'
about: { block: true },
count: { kind:"number", block: true }, // <-- INVALID: 'BLOCK' ISN'T ON NUMBER!
})
The core of the issue is that, as written,
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
does not distribute over unions in T
. You clearly intend that PartialBy<T1 | T2 | T3, K>
will evaluate to PartialBy<T1, K> | PartialBy<T2, K> | PartialBy<T3, K>
. But it doesn't. Neither the Pick
nor the Omit
utility types distribute over unions. (See Why doesn't discriminated union work when I omit/require props?) Instead they act on unions at once, collapsing them to single object types:
That means
type TLabeledField = PartialBy<TField, "name" | "field" | "kind">
evaluates to
type TLabeledField = Omit<TField, "name" | "field" | "kind"> &
Partial<Pick<TField, "name" | "field" | "kind">>
which is equivalent to
type TLabeledField = {
placeholder?: string;
required?: boolean;
value?: string | number | boolean;
name?: string;
field?: string;
kind?: "string" | "number" | "boolean" | "password" | "email";
}
and that's not how you want TLabeledField
to behave.
You can take any generic type and make it distribute over the type parameter by wrapping it in a distributive conditional type. If NonDistrib<T>
is not distributive, you can write type Distrib<T> = T extends unknown ? NonDistrib<T> : never
. You're not really trying to check T extends unknown
(the intent is that it should always be true, and so you can also write T extends any
or T extends T
or something else always true). It's just that doing so makes the type distributive.
That gives you
type PartialBy<T, K extends keyof T> = T extends unknown ?
Omit<T, K> & Partial<Pick<T, K>> :
never;
And now
type TLabeledField = PartialBy<TField, "name" | "field" | "kind">
evaluates to
type TLabeledField = (Omit<{
field: string; name: string;
placeholder?: string; required?: boolean;
} & {
kind: "string"; value?: string; block?: boolean;
}, "name" | "field" | "kind"> & Partial<Pick<{
field: string; name: string;
placeholder?: string; required?: boolean;
} & {
kind: "string"; value?: string; block?: boolean;
}, "name" | "field" | "kind">>) | (Omit<{ ? */
which expands to
type TLabeldField = {
placeholder?: string; required?: boolean; value?: string;
block?: boolean;
name?: string; field?: string; kind?: "string";
} | {
placeholder?: string; required?: boolean; value?: string;
name?: string; field?: string; kind?: "password";
} | {
placeholder?: string; required?: boolean; value?: string;
name?: string; field?: string; kind?: "email";
} | {
placeholder?: string; required?: boolean; value?: number;
name?: string; field?: string; kind?: "number";
} | {
placeholder?: string; required?: boolean; value?: boolean;
inline?: boolean;
toggle?: boolean;
name?: string; field?: string; kind?: "boolean";
} */
and thus your code now behaves as desired.