marked icon indicating copy to clipboard operation
marked copied to clipboard

Cannot narrow Token type since v7.0.2

Open rokoucha opened this issue 2 years ago • 17 comments

Marked version: v7.0.2

Describe the bug Since Marked v7.0.2, cannot narrow Token type.

Tokens.Generic was added to Token in #2891 but this change makes cannot narrow Token type by Token.type. https://github.com/markedjs/marked/blob/ff1602c17843ba614bc8024b905e7a3b51904d2d/src/Tokens.ts#L23

To Reproduce Steps to reproduce the behavior:

import type { Token } from 'marked'

const t: Token = {
  type: 'text',
  raw: 'test',
  text: 'test',
}
if (t.type === 'text') {
  t // const t: (Tokens.Text & {
    //   loose?: boolean | undefined;
    //   tokens?: Token[] | undefined;
    // }) | (Tokens.Generic & {
    //   loose?: boolean | undefined;
    //   tokens?: Token[] | undefined;
    // })
  t.text // (property) Tokens.Text.text: any
}

Expected behavior

import type { Token } from 'marked'

const t: Token = {
  type: 'text',
  raw: 'test',
  text: 'test',
}
if (t.type === 'text') {
  t // const t: Tokens.Text & {
    // loose?: boolean | undefined;
    // tokens?: Token[] | undefined;
    // }
  t.text // (property) Tokens.Text.text: string
}

rokoucha avatar Aug 13 '23 16:08 rokoucha

I've been running into this as well with fixing types in #2893

But even though this is less convenient it is actually correct. Just because a token has type 'text' doesn't mean that token wasn't created by an extension and returned as a Generic token with whatever properties it wants.

I don't know the best way to solve this. Either we can narrow by type or we can have a correctly typed variable. We can't do both.

UziTech avatar Aug 13 '23 16:08 UziTech

I don't know if there is a way in typescript to say the Generic token type is a string but not one of the other type strings.

UziTech avatar Aug 13 '23 16:08 UziTech

An extension should override the type of Token in the right type and Marked should provide the correct type in their implementation of Marked, IMO. Types other than Generic have useless now because it is not possible to narrow by type.

rokoucha avatar Aug 13 '23 17:08 rokoucha

It is still possible to narrow with const textToken = token as Token.Text.

If you have a better idea on how to fix it PRs are always welcome 😁👍

UziTech avatar Aug 13 '23 18:08 UziTech

IMO that is actually better, because even though it is possible that token is not type Token.Text if the type === 'text' it is explicitly stated in the code that we think token is Token.Text. If there is ever a bug because the token is not a Token.Text it is easier to see what assumptions we might have gotten wrong.

UziTech avatar Aug 13 '23 18:08 UziTech

I do agree that we should limit the type string in Generic to any string that is not one of the main token types. If you can find a way to do that in typescript that would be very helpful.

UziTech avatar Aug 13 '23 18:08 UziTech

I'm confusing how to identify a Token safely without Token.type. How do I identify 'space' and 'hr' without Token.type? they are the same keys and type of value.

I believe Marked will return the correct Token and can identify that with Token.type if never extensions are loaded. is that wrong?

rokoucha avatar Aug 13 '23 19:08 rokoucha

I agree that token.type should identify the token. I just don't know how to do that in typescript when Token.Generic.type can be any string.

Using as Token.Text just tells typescript that even though this could be Token.Generic I assume it is Token.Text.

The solution is not as simple as removing Token.Generic from Token. That would cause a lot of places to use any which is what was fixed in 7.0.2

UziTech avatar Aug 13 '23 20:08 UziTech

I'm trying to improve the type of Token without Token.Generic in https://github.com/rokoucha/marked/tree/improve-token-typing . It passes the tests and type-checking, and it's good enough, but what's the problem?

rokoucha avatar Aug 19 '23 16:08 rokoucha

I don't like non-null assertion operators and it seemed to me that some token objects were of the wrong type.

rokoucha avatar Aug 19 '23 17:08 rokoucha

what's the problem?

First thing I see is that the extension tokenizer can only return one of the current tokens. This is totally wrong and will break almost every extension. Extension tokenizers can return any object as a token as long as it has a type and raw property. Hence the Tokens.Generic type.

UziTech avatar Aug 19 '23 23:08 UziTech

OK, I see... It's impossible to implement types that are extended by extensions before loading extensions, so I think Marked should provide extensible types, not Generic types.

For example, declaration merging and ambient module declarations allow extensions to define the correct types.

extend.d.ts

import 'marked'

declare module 'marked' {
  // It's unsightly but I have to do this
  export type Token = (
    | Tokens.Space
    | Tokens.Code
    | Tokens.Heading
    | Tokens.Table
    | Tokens.Hr
    | Tokens.Blockquote
    | Tokens.List
    | Tokens.ListItem
    | Tokens.Paragraph
    | Tokens.HTML
    | Tokens.Text
    | Tokens.Def
    | Tokens.Escape
    | Tokens.Tag
    | Tokens.Image
    | Tokens.Link
    | Tokens.Strong
    | Tokens.Em
    | Tokens.Codespan
    | Tokens.Br
    | Tokens.Del
    | Tokens.Custom // add custom Token type here
  ) & {
    loose?: boolean
    tokens?: Token[]
  }

  namespace Tokens {
    // Extend existing token
    interface Text {
      extended: true
    }
    // Something completely new token
    interface Custom {
      type: 'custom'
      customize: true
    }
  }
}

example.ts

import { Token, Tokens } from 'marked'

const text = {
  type: 'text',
  text: 'hoge',
  raw: 'hoge',
  extended: true,
} satisfies Tokens.Text // pass!

const custom = {
  type: 'custom',
  customize: true,
} satisfies Tokens.Extended // it also passes!!

const _ts = [text, extended] satisfies Token[] // this will also pass!!!

rokoucha avatar Aug 20 '23 16:08 rokoucha

rebase on the latest version of marked. I fixed a few types and removed & { loose?: boolean, tokens?: Token[] } from the Token type.

It could work to extend the token types with what extensions could return but I don't know enough about typescript to know what that would look like. However the fact still remains that an extension can use one of the current types but not have the same properties. I think this is just a limitation of typescript that we can't say an extension can return an object with type "text" but it must have these properties.

UziTech avatar Aug 20 '23 17:08 UziTech

Yes, TypeScript will merge interfaces so cannot override existing tokens by extensions. I'm looking for a way, but haven't found one yet.

rokoucha avatar Aug 20 '23 19:08 rokoucha

It's annoying not being able to narrow, and I haven't invest much time but feel like there is a better way to allow generic / custom tokens (maybe it's on marked to accept Token | TokenGeneric wherever it accepts Token), but this will work for downstream users:

function isMarkedToken<T extends Token>(
  obj: unknown,
  markedType: T['type'],
): obj is T {
  return (
    typeof obj === 'object' &&
    obj != null &&
    'type' in obj &&
    obj.type === markedType
  )
}

const token: Token = ...
if (isMarkedToken<Tokens.Heading>(token, 'heading')) {
  // token is `Token.Heading`
}

mikew avatar Mar 23 '24 19:03 mikew