Skip to content

Instantly share code, notes, and snippets.

@orta
Last active February 20, 2024 02:23
Show Gist options
  • Save orta/f80db73c6e8211211e3d224a5ab47624 to your computer and use it in GitHub Desktop.
Save orta/f80db73c6e8211211e3d224a5ab47624 to your computer and use it in GitHub Desktop.
A proposal for improving TS2322 error messages

This is a work in progress. Please don't take this as something that will definitely happen, we all know what happens to well laid plans and I need to present it to the rest of the TypeScript team in order to figure out a lot of feasibility questions.

Intro

The examples in this PR assumes [CLI DX] Improve positioning of compiler error messaging info #45717 is merged

In 4.4, all diagnostic messages from TypeScript are treated the same, we have a massive .JSON file of ±2000 diagnostic messages which are used everywhere from compiler messages to CLI help. Aside from some simple string manipulation, these are effectively what we output for all error messages. I'd like to propose that we break this pattern, just for error TS2322.

TS2322 is our 'type x is not assignable to y' error, you'd see it for const str: string = 123 and I expect it is the most seen error message by far.

With this RFC I propose that we add a new system for printing TS2322 errors which uses a different visual metaphor to error display these errors. This document first describes the overall idea, thens offers a set of potential independent extensions for improvements in particular cases.

Code and Columns

Starting with something easy:

let a = { b: { c: { e: { f: 123 } } } };
let b = { b: { c: { e: { f: "123" } } } };
a = b;

Looks like this on the CLI today:

$ /Users/ortatherox/dev/typescript/repros/compilerSamples/node_modules/.bin/tsc

● index.ts:4:1                                                                        TS2322
  │ a = b
    ▔
  Type '{ b: { c: { e: { f: string; }; }; }; }' is not assignable to type '{ b: { c: { e: {
  f: number; }; }; }; }'.

  The types of 'b.c.e.f' are incompatible between these types.
    Type 'string' is not assignable to type 'number'.

I think we can first treat the type as a native object (vs a string) and for small or unknown terminal widths, we can simply add some newlines.

$ /Users/ortatherox/dev/typescript/repros/compilerSamples/node_modules/.bin/tsc

● index.ts:4:1                                                                        TS2322
  │ a = b
    ▔
  Type:
  { b: { c: { e: { f: string; }; }; }; }

  is not assignable to type:
  { b: { c: { e: { f: number; }; }; }; }

  The types of 'b.c.e.f' are incompatible between these types.
    Type 'string' is not assignable to type 'number'.

When we do know the width of the terminal however, I think we can go a bit further and show the types side-by-side.

$ /Users/ortatherox/dev/typescript/repros/compilerSamples/node_modules/.bin/tsc

● index.ts:4:1                                                                        TS2322
  │ a = b
    ▔
   Type:                  is not assignable to type:

   {                   │  {
     b: {              │    b: {
       c: {            │      c: {
         e: {          │        e: {
           f: string;  │          f: number;
         };            │        };
       };              │      };
     };                │    };
   }                   │  }


  The types of 'b.c.e.f' are incompatible between these types.
    Type 'string' is not assignable to type 'number'.

I have some pretty detailed notes about how we can handle reflow, trimming, hiding type details in these objects etc, but this RFC is about the high level concept, and I'm gonna skip some of those details here. I think we can chop these types to roughly the stuff to avoid showing every field in the CSStyles type for example, but like with the current error messages - I'm sure there'll be cases where it's info overload.

Too many lines to have by default?

This change takes 4 lines of error message text and converts it to 14, which is pretty drastic increase. If this occured to many TS2322s in a build, that's going to really make text output of tsc much longer. It's not a great idea to make these any more context-sensitive than knowing about column length, so it could be that we need this to live behind a compiler flag. Perhaps either overriding pretty with a string, or adding a new flag like:

{
  "extensiveAssignabilityInformationDiagnostics": true
}

Which is kinda accurate from a TS compiler dev's perspective, but maybe somethig more like "prettyAssignmentInformation" might make more sense to less experienced users?

This can be turned on by default in tsc --init.

Extensions

These could be considered separate concepts which can build on the above foundations as adding value in specific messages.

Value Naming

If the type does not have a name we can try a name from the closest named value, which can be added into this structure quite elegantly:

● index.ts:4:1                                                                        TS2322
  │ a = b
    ▔
   Type of 'a':             is not assignable to type of 'b':

   {                   │  {
     b: {              │    b: {
       c: {            │      c: {
         e: {          │        e: {
           f: string;  │          f: number;

    // ...

This could be container-ish aware, so for example an assignability issue in a JS literal inside a named array could say "Type of object 3 in 'optionsForWatch'".

Literal vs Type

The previous example has a layer of indirection because we have var a =... and then a = b later. So, we have 'where assignability issue occured' + 'source type' + 'target type'. That means to show the necessary info we need to present 3 separate pieces of information. However, in many cases the source type and assignability are strongly linked.

interface A { abc: string; }
const a: A[] = [{ abc: 123 }];
//                ~~~

Which today looks like:

● index.ts:2:19                                                                       TS2322
  │ const a: A[] = [{ abc: 123 }]
                      ▔▔▔
  Type 'number' is not assignable to type 'string'.

  The expected type comes from property 'abc' which is declared here on type 'A'
  index.ts:1:15

  │ interface A { abc: string }
                  ▔▔▔

Could look like:

● index.ts:4:1                                                                        TS2322
  │ const a: A[] = [{ abc: 123 }]
                      ▔▔▔
   Object:               is not assignable to A:

   {                   │  {
     abc: 123          │    abc: string
   }                   │  }
   // ...

Subtype Diagnostic Chains.

Take this code:

var b1: { f(x: string): void };
var b2: { f(x: number): void };
b1 = b2;

Which gives a message like:

$ /Users/ortatherox/dev/typescript/repros/compilerSamples/node_modules/.bin/tsc

● index.ts:4:1                                                                        TS2322
  │ b1 = b2
    ▔▔▔▔▔▔▔
  Type '{ f(x: string): void; }' is not assignable to type '{ f(x: number): void; }'.

  Types of property 'f' are incompatible.
    Type '(x: number) => void' is not assignable to type '(x: string) => void'.
      Types of parameters 'x' and 'x' are incompatible.
        Type 'string' is not assignable to type 'number'.

To be able to say that the issue occurs inside the parameters we first highlight that the error exists during assignment, but the the issue lays a few AST children deep into the things being compared. This means there are up-to 5 useful bits of information:

  • The line where the assignment happened
  • The target type
  • The subtype/property inside the target which mismatched
  • The source type
  • The subtype/property inside the source which mismatched

Today we show you that by showing the two types, then we build a diagnostic chain to lead you to the subtype information. We could replace many cases of needing to use the dignostic chain by adding that information into the type match text:

● index.ts:4:1                                                                        TS2322
  │ b1 = b2
    ▔▔▔▔▔▔▔
   Type:                  is not assignable to type:

   {                     │  {
     f(x: string): void  │    f(x: number): void
          ▔▔▔▔▔▔         │         ▔▔▔▔▔▔
   }                     │  }


  Types of property 'f' are incompatible.
This is a bit of an easy case, what about something more complex?

I've grabbed a compiler test with a long diagnostic chain which jumps across two types.

const assignability1: () => AsyncIterableIterator<number> = async function * () {
//    ▔▔▔▔▔▔▔▔▔▔▔▔▔▔
    yield "a";
};

Looks like:

● index.ts:4:1                                                                        TS2322
  │ const assignability1: () => AsyncIterableIterator<number> = async function * () {
          ▔▔▔▔▔▔▔▔▔▔▔▔▔▔

  Type '() => AsyncGenerator<string, void, undefined>' is not assignable to type '() => AsyncIterableIterator<number>'.

  Call signature return types 'AsyncGenerator<string, void, undefined>' and 'AsyncIterableIterator<number>' are incompatible.
    The types returned by 'next(...)' are incompatible between these types.
      Type 'Promise<IteratorResult<string, void>>' is not assignable to type 'Promise<IteratorResult<number, any>>'.
        Type 'IteratorResult<string, void>' is not assignable to type 'IteratorResult<number, any>'.
          Type 'IteratorYieldResult<string>' is not assignable to type 'IteratorResult<number, any>'.
            Type 'IteratorYieldResult<string>' is not assignable to type 'IteratorYieldResult<number>'.
              Type 'string' is not assignable to type 'number'.

Could look like:

● index.ts:4:1                                                                        TS2322
  │ const assignability1: () => AsyncIterableIterator<number> = async function * () {
          ▔▔▔▔▔▔▔▔▔▔▔▔▔▔

  Type:
  interface AsyncGenerator<string, void, undefined> extends AsyncIterator<string, void, undefined> {
    next(...args: [] | [TNext]): Promise<IteratorResult<string, void>>;
                                                        ▔▔▔▔▔▔
  }

  Is not assignable to:
  interface AsyncIterator<number, TReturn = any, TNext = undefined> {
      next(...args: [] | [TNext]): Promise<IteratorResult<number, TReturn>>;
                                                          ▔▔▔▔▔▔
  }

  Call signature return types 'AsyncGenerator<string, void, undefined>' and 'AsyncIterableIterator<number>' are incompatible.
    Type 'string' is not assignable to type 'number'.

Inline 'Did You Mean'?

This might be something we want in all messages, but one of the reasons I recommend folks 'look at the bottom of long compiler messages first' is because the "did you mean" is down there. We could special case that specific signal and move it up into the type information:

interface Book { foreword: string; }
const b: Book = { forword: "oops" };
//                ~~~~~~~~~~~~~~~
● index.ts:2:19                                                                       TS2322
  │ const b: Book = { forword: "oops" };
                      ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
  Object for 'b':        is not assignable to Book:

  {                              │  {
     forword: "oops"             │    foreword: string
     ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔             │  }
     ! Did you mean 'foreword'?  │
  }                              │

  Object literal may only specify known properties, but 'forword' does not exist in type 'Book'.

There's a unicode snowman, but not a unicode lightbulb - so !, ʘ, ߷, or something.

Vertical Position Matching

If it's a mismatcthed type on a literal, we can vertically align the types to help readability

● src/compiler/commandLineParser.ts:1169:13                                                                            TS2322

  Type:  "strong"               is not assignable to type:

  {                          |
    name: "declarationDir",  │
    type: "strong",          │  "number" | "string" | "boolean" | "object" | ESMap<string, string | number> | "list"
    ▔▔▔▔                     │
    affectsEmit: true,       │
    ...                      │
  }                          │

  The expected type comes from property 'type' which is declared here on type 'CommandLineOption'
  src/compiler/types.ts:6272:9

  │ export interface CommandLineOptionOfPrimitiveType extends CommandLineOptionBase {
  │   type: "string" | "number" | "boolean";
  │ }

Target Union Sorting

When we have union members being compared to a source string literal, we could sort the target union according to their levenshtein distance to source, so that you can basically read from left to right to understand the error message.

● src/compiler/commandLineParser.ts:1169:13                                                                            TS2322

  Type:  "strong"               is not assignable to type:

  {                          |
    name: "declarationDir",  │
    type: "strong",          │  "string" | "number" | "boolean" | "object" | ESMap<string, string | number> | "list"
    ▔▔▔▔                     │

JSX Literals

If we support showing object literals as the target, then it seems quite reasonable to have JSX literals be available too. For example, I've just changed a line of code in the TypeScript website:

❯ yarn workspace typescriptlang-org tsc --noEmit
● src/templates/pages/index.tsx:66:27                                                   TS2322
  │ <Link to={props.href} classame={"get-started " + props.classes}>
                          ▔▔▔▔▔▔▔▔
  Type '{ children: Element[]; to: string; classame: string; }' is not assignable to type 'IntrinsicAttributes & GatsbyLinkProps<{}>'.

  Property 'classame' does not exist on type 'IntrinsicAttributes & GatsbyLinkProps<{}>'. Did you mean 'className'?

Could be:

● src/templates/pages/index.tsx:66:27                                                   TS2322
  │ <Link to={props.href} classame={"get-started " + props.classes}>

  Type:                               is not assignable to type: IntrinsicAttributes & GatsbyLinkProps<{}>'
                                  │
  <Link                           │  {
    to: string                    │    children: Element[];
    classame: string              │    to: string;
    ~~~~~~~~                      │    className: string;
  >                               │  }

  Property 'classame' does not exist on type 'IntrinsicAttributes & GatsbyLinkProps<{}>'.

( children: Element[] could be shown by > vs /> though that may be too subtle for newbies to pick up. )

Hyperlinked Types

Many terminals (iTerm 2, Windows Terminal, GNOME Terminal etc (and may have been recently un-blocked for vscode)) support hyperlinks on text which can load a file in your editor. We can use this in the type printer to support clicking on any named type to jump to its declaration.

Syntax Highlight

While we don't need something as advanced as shiki, there is a semantic highlighter inside TypeScript (in the editor/services subsystem) but we could what babel-highlight does which is a very naive syntax token to color map.

Returning More Structured Data to Editors

Some editors support passing back markdown for hover information - we could use this system to provide a markdown-flavoured version of this message:

Screen Shot 2021-09-06 at 5 20 24 PM

It's a little weird, because it'd be the only error message which gets the treatment, but it's not hard to imagine that we extend the type-printer in a way which knows it's is in a markdown context and it provides the codeblock syntax and then this concept could be back-ported to all other errors.

@orta
Copy link
Author

orta commented Sep 6, 2021

Yeah, I think it might be interesting to explore having a colored or something which sits next to the two types to help guide your eyes. I've added notes about syntax highlighting, thanks, I think it's a solid extension.

@orta
Copy link
Author

orta commented Sep 8, 2021

Request form Eli - can also have an ENV var to override extensiveAssignabilityInformationDiagnostics?

@telfDavid
Copy link

so cool...

@orta
Copy link
Author

orta commented Jul 4, 2023

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