Skip to content

Better type error message for <MatchTag> with missing cases #1055

Description

@andjsrk

Describe The Problem To Be Solved

Currently MatchTag uses overloads to determine user's intent.
While it is straightforward when reading the declaration, when there are missing cases, it generates an error message that is hard to understand:

type MyUnion =
  | {
      kind: "foo";
      foo: "foo-value";
    }
  | {
      kind: "bar";
      bar: "bar-value";
    };

<MatchTag
  on={value()}
  // No overload matches this call.
  //   The last overload gave the following error.
  //    Type 'MyUnion' is not assignable to type '{ type: PropertyKey; } | null | undefined'.
  //      Property 'type' is missing in type '{ kind: "foo"; foo: "foo-value"; }' but required in type '{ type: PropertyKey; }'.
  tag="kind"
  case={{
  }}
/>;

As you can see, the error message only shows type is missing from your on value, which is the result of evaluating the last overload. (to be honest, because of this I thought the author did not consider tag's value in the type declaration at first glance.)
This is quite confusing, isn't it?

Suggest A Solution

I suggest unifying the overloads using conditional types.

My sketch:
Playground

Code block (long)
import type { Accessor, JSX } from 'solid-js'

type Cases<IsPartial extends boolean, T> =
    boolean extends IsPartial
        ? { 'no `boolean` allowed, please use either `true` or `false`': never }
        : true extends IsPartial
            ? Partial<T>
            : Required<T>

type NotProvidedOrTyped<K extends string, V, Default> =
    | (V extends Default ? Partial<Record<K, never>> : never)
    | Required<Record<K, V>>

type Tag = string | number
type TagKeyDefault = "type"
type PartialDefault = false
declare function MatchTag<
    T extends { [k in TagKey]: Tag },
    TagKey extends string = TagKeyDefault,
    IsPartial extends boolean = PartialDefault,
>(
    props:
        & {
            on: T | null | undefined;
            case: Cases<IsPartial, { [Tag in T[TagKey]]: (v: Accessor<Extract<T, Record<TagKey, Tag>>>) => JSX.Element }>;
            fallback?: JSX.Element;
        }
        & NotProvidedOrTyped<"tag", TagKey, TagKeyDefault>
        & NotProvidedOrTyped<"partial", IsPartial, PartialDefault>
): JSX.Element;

type MyUnion =
  | {
        kind: "foo";
        foo: "foo-value";
    }
  | {
        kind: "bar";
        bar: "bar-value";
    };

declare const value: () => MyUnion

// normal case
<MatchTag
    on={value()}
    tag="kind"
    case={{
        foo: props => <>{props().foo}</>,
        bar: props => <>{props().bar}</>,
    }}
/>;

// edge cases
<MatchTag
    on={value()}
    tag="kind"
    case={{}}
    // much more clear error message:
    // Type '{}' is missing the following properties from
    // type 'Required<{
    //   foo: (v: Accessor<{ kind: "foo"; foo: "foo-value"; }>) => Element;
    //   bar: (v: Accessor<{ kind: "bar"; bar: "bar-value"; }>) => Element;
    // }>': foo, bar
/>;
<MatchTag<MyUnion, "kind">
    // properly handles "explicit generic argument without providing optional arguments" hazard (see https://github.com/microsoft/TypeScript/issues/58977)
    on={value()}
    // `tag` prop is not actually provided
    case={{
        foo: () => <></>,
        bar: () => <></>,
    }}
/>;
<MatchTag<MyUnion, "kind", true>
    // handles `partial` too!
    on={value()}
    tag="kind"
    case={{}}
    // `partial` prop is not actually provided
/>;
<MatchTag<MyUnion, "kind", boolean>
    on={value()}
    tag="kind"
    case={{}} // bans ambiguous `partial: boolean`
/>;

type MyUnionWithTypeTag =
  | {
        type: "foo";
        foo: "foo-value";
    }
  | {
        type: "bar";
        bar: "bar-value";
    };

declare const valueTypeTag: () => MyUnionWithTypeTag

// normal case
<MatchTag
    on={valueTypeTag()}
    case={{
        foo: props => <>{props().foo}</>,
        bar: props => <>{props().bar}</>,
    }}
/>;

// edge cases
<MatchTag
    on={valueTypeTag()}
    case={{}}
/>;

I tried to write the code easy to read, but I understand it still is hard to read.
Feedbacks appreciated!

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions