Search code examples
typescripttypestypescript-genericstype-inference

Inferring a type


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!
})

Solution

  • 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.

    Playground link to code