1

Example interface.

interface Model {
  id: number;
  name: string;
  address: {
    street: string;
    zip: number;
    post: string;
    country: 'US' | 'GB' | 'IE' | 'CA' | 'AU';
  }
}

I have a component that displays the details of some object instance (in this case it will be an instance of Model). This component requires the object model instance and a definition that describes how individual properties should be displayed.

interface BaseDefinition {
  label: string;
}
interface SimpleDefinition<T> extends BaseDefinition {
  property: Paths<T>;
}
interface FormattedGeneralDefinition<T> extends BaseDefinition {
  format: (value: T) => string;
}
interface FormattedSpecificDefinition<T, TPath in Paths<T>> extends BaseDefinition {
  property: TPath;
  format: (value: TypeofPath<T, TPath>, instance: T) => string;
}

type Definition<T> = SimpleDefinition<T> | FormattedGeneralDefinition<T> | {[K in Paths<T>]: FormattedSpecificDefinition<T, K>}[Paths<T>];

For Model this equates to expected union of:

type Definition<Model> = {
    label: string;
    field: "id" | "name" | "address" | "address.street" | "address.zip" | "address.post" | "address.country";
} | {
    label: string;
    format: (instance: Model) => string;
} | {
    label: string;
    field: "id";
    format: (value: number, instance: Model) => string;
} | {
    label: string;
    field: "name";
    format: (value: string, instance: Model) => string;
} | {
    label: string;
    field: "address";
    format: (value: {
        street: string;
        zip: number;
        post: string;
        country: 'CH' | 'SI' | 'AT' | 'DE';
    }, instance: Model) => string;
} | {
    label: string;
    field: "address.street";
    format: (value: string, instance: Model) => string;
} | {
    label: string;
    field: "address.zip";
    format: (value: number, instance: Model) => string;
} | {
    field: "address.post";
    format: (value: string, instance: Model) => string;
    label: string;
} | {
    label: string;
    field: "address.country";
    format: (value: "CH" | "SI" | "AT" | "DE", instance: Model) => string;
}

The Paths<T> type is a utility type that resolves to a union of all dot delimited property paths of a given type (like a deep keyof with dot delimited notation - implementation also on Stackoverflow).

In the case of this example the Paths would resolve to a union of all property paths:

Paths<Model> = 'id' | 'name' | 'address' | 'address.street' | 'address.zip' | 'address.post' | 'address.country';

The TypeofPaths<T, TPath> type is a type I've written that returns the type of a model property as selected using the dot-delimited string.

In the case of this example the TypeofPath<Model, 'address.country'> would resolve to:

TypeofPath<Model, 'address.country'> = 'US' | 'GB' | 'IE' | 'CA' | 'AU';

This type is defined as

type TypeofPath<T extends Record<keyof T, unknown>, TPath extends Paths<T>> = TPath extends keyof T
  ? T[TPath]
  : TPath extends `${infer TLeft extends Extract<keyof T, string | number>}.${infer TRight}`
    ? TRight extends Paths<T[TLeft]>
      ? TypeofPath<T[TLeft], TRight>
      : never
    : never;

The problem

When I try to define my definition object with format function, definition type doesn't seem to be inferred correctly by the provided properties and their values:

const d: Definition<Model>[] = [{
  // this one works as expected
  label: 'Simple',
  field: 'address.country'
},{
  label: 'Formatted General',
  // Parameter 'model' implicitly has an 'any' type.(7006)
  // 'model' should be of type 'Model'
  format: (model) => `Country is ${model.address.country}`
},{
  label: 'Formatted Specific',
  field: 'address.zip',
  // Parameter 'zip' implicitly has an 'any' type.(7006)
  // 'zip' should be of type 'number'
  // Parameter 'model' implicitly has an 'any' type.(7006)
  // 'model' should be of type 'Model'
  format: (zip, model) => `Zip is ${zip} and country is ${model.address.country}`
}];

I've feel I'm so close to solution, but I don't know what exactly I'm missing so that the definition object types don't implicitly get resolved. Apparently it seems that my Definition is not a full discriminated union of types because otherwise I suppose this should work. If course if I explicitly cast them, then everything works as expected.

const d: Definition<Model>[] = [{
  label: 'Simple',
  field: 'address.country'
},{
  label: 'Formatted General',
  format: (model) => `Country is ${model.address.country}`
} as FormattedGeneral<Model>,{
  label: 'Formatted Specific',
  field: 'address.zip',
  format: (zip, model) => `Zip is ${zip} and country is ${model.address.country}`
} as FormattedSpecific<Model, 'address.zip'>];

Naturally, I don't want explicit casting as field property should already provide all the information to resolve the correct instance type.

You're welcome to test these types in the Typescript playground. There's an additional type in there that helps with type resolution of the Definition<T> type.

Robert Koritnik
  • 103,639
  • 52
  • 277
  • 404
  • 1
    That's quite a lot of code and fairly complicated type definitions; could you pare it all down to something like [this](https://tsplay.dev/WYepzW) so that it's a [mre] without the distractions of recursive conditional template literal type stuff? If so then people can investigate why contextual typing fails. If not then what am I missing? – jcalz Apr 19 '23 at 15:04
  • 1
    And assuming that's okay, then [these](https://tsplay.dev/NnEdkw) are the changes you need to make to have it be a "good" discriminated union. Can you reverse engineer that to fix your issue, or do you need someone to walk through it? Let me know how to proceed here. – jcalz Apr 19 '23 at 15:11
  • @jcalz Your resolved types actually reproduce the issue, yes. My example just provides all the types that construct this end result. Why? Because my construction may be wrong so your end result of those "acrobatics" may be invalid from the get go point. But all in all your example has the same issue I'm asking about. – Robert Koritnik Apr 20 '23 at 07:48
  • @jcalz And thanks for the change. I assumed I've messed up the discriminating union. I already decontructed all types related to the `SimpleDefinition` to not include the union of prop paths. I'll dissect your solution to make mine work as expected. Do put it in an answer so I'll be able to either comment on it directly and accept it? – Robert Koritnik Apr 20 '23 at 07:52
  • 1
    I've never heard "either/and" before; can you clarify what you mean by "I'll be able to either comment on it directly and accept it?" Assuming you mean "either/or": If there's a chance you won't accept it I won't bother writing up an answer (I tend to spend a good deal of effort on those); if you have some issue I'd prefer hearing about it now instead of later so I don't have to rewrite an answer. Let me know how you'd like to proceed. – jcalz Apr 20 '23 at 13:51
  • It's just a language barrier. I've resolved my problem using your example and it worked. I was able to create proper discriminating union of those types and they work. Thank you. What I meant by *comment on* is if there would be any additional questions. But Since I don't have any any more, I'll just accept your answer if you would be so kind and write one. You've surely pointed me in the right direction. **Thank you**. – Robert Koritnik Apr 20 '23 at 15:41

1 Answers1

1

The main problem is that your definition of the discriminated union Definition<Model> type has some overlaps among union members, and when the compiler tries to discriminate an object literal it can't do so quickly enough to contextually type the format callback parameter. The fix is to make the union more specific by consolidating some members and adding extra information to others.


For ease of discussion I will shorten Model to

interface Model {
  address: { zip: number; country: string; }
}

and examine Definition<Model> without worrying abut the details of how Definition is implemented:

type DefinitionModel =
  {
    field: "address" | "address.zip" | "address.country";
    label: string;
  } | {
    format: (instance: Model) => string;
    label: string;
  } | {
    field: "address";
    format: (value: { zip: number; country: string; }, instance: Model) => string;
    label: string;
  } | {
    field: "address.zip";
    format: (value: number, instance: Model) => string;
    label: string;
  } | {
    field: "address.country";
    format: (value: string, instance: Model) => string;
    label: string;
  };

Given this definition, let's look at how to resolve the errors:

const v1: DefinitionModel = {
  label: 'Formatted General',
  format: (model) => `Country is ${model.address.country}`
  //       ~~~~~ <-- implicit any error
};

The problem with v1 is that the only properties present are label and format, neither of which are discriminant properties. Presumably the compiler could use the absence of field to narrow the type, but the union member { format: (instance: Model) => string; label: string; } merely fails to mention field; it doesn't say that it's definitely missing. To fix this, you can change that member to have an optional field property whose value is of the impossible never type:

type DefinitionModel =
  { ⋯ } | {
    field?: never; // <-- add this
    format: (instance: Model) => string;
    label: string;
  } | { ⋯ } | ⋯ ;

const v1: DefinitionModel = {
  label: 'Formatted General',
  format: (model) => `Country is ${model.address.country}`
}; // okay

Now the absence of field is itself a discriminant, and things go smoothly.


Next there's this:

const v2: DefinitionModel = {
  label: 'Formatted Specific',
  field: 'address.zip',
  format: (zip, model) => `Zip is ${zip} and country is ${model.address.country}`
  //       ~~~  ~~~~~ <-- implicit any error
};

That one fails because both it is assignable to both { field: "address" | "address.zip" | "address.country"; label: string; } and { field: "address.zip"; format: (value: number, instance: Model) => string; label: string; }. Again, just because a type fails to mention a property it doesn't mean that such a property is prohibited. You could try to add format?: never to that member, but it still doesn't discriminate things fast enough for contextual typing to work.

Instead, my suggestion here is to refactor to get rid of { field: "address" | "address.zip" | "address.country"; label: string; } completely, and just make format optional in all the respective union members related to it. That leaves us with:

type DefinitionModel = {
    field?: never; // this was the other problem
    format: (instance: Model) => string;
    label: string;
  } | {
    field: "address";
    format?: (value: { zip: number; country: string; }, instance: Model) => string;
    label: string;
  } | {
    field: "address.zip";
    format?: (value: number, instance: Model) => string;
    label: string;
  } | {
    field: "address.country";
    format?: (value: string, instance: Model) => string;
    label: string;
  };

and now

const v2: DefinitionModel = {
  label: 'Formatted Specific',
  field: 'address.zip',
  format: (zip, model) => `Zip is ${zip} and country is ${model.address.country}`
}; // okay

works.


So that's the basic approach. Presumably you want to modify Definition<T> to behave this way instead, possibly like this:

type Definition<T> = { field?: never, format: (instance: T) => string, label: string } | (
  Exclude<PathValue<T>, { path: "" }> extends infer PV ?
  PV extends { path: infer P extends PropertyKey, value: infer V }
  ? { field: P, format?: (value: V, instance: T) => string, label: string } : never : never
)

type PathValue<T> = { path: "", value: T } | (T extends object ?
  { [K in keyof T]: PathValue<T[K]> extends infer PV ?
    PV extends { path: infer P extends PropertyKey, value: infer V } ?
    { path: P extends "" ? K : `${Exclude<K, symbol>}.${Exclude<P, symbol>}`, value: V }
    : never : never }[keyof T]
  : never)

but I'm going to consider explaining that out of scope here (since it would take quite a lot of space to do that) and assume that the interested reader can either do it themselves or ask a new question post if they need more details about it.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Thank you for writing such a detailed answer that helped resolve my discriminated union issue. Immediately accepted (and upvoted). My code now works exactly as I intended it to. – Robert Koritnik Apr 21 '23 at 08:48