solidjs-community / solidjs-community/solid-primitives

Better type error message for `<MatchTag>` with missing cases

Open
#1,055 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

enhancement
Dominant language
TypeScript
Stars
1.6k
Forks
162
Avg merge
19h 40m
Merged PRs (30d)
8

Description

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 (simple test cases included):
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!

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by locating the MatchTag declaration and reviewing the linked TypeScript Playground, then compare the current overload diagnostics with the proposed conditional-type examples. Done means missing cases produce an understandable error, while the normal, explicit-generic, partial, and default-tag examples retain their intended type behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
frontend
Issue type
Feature
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.