Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: marked and parse type overload with discrinated union options #3116

Open
wants to merge 2 commits into
base: master
Choose a base branch
from

Conversation

Yimiprod
Copy link

@Yimiprod Yimiprod commented Dec 1, 2023

Marked version: 11.0.0

Description

Fixes typescript expected error which force to use as string or as Promise or ts-expect-error comments by using disciminated union for options.

Added two type guards to detect which options object is used.

Expectation

  • as string or as Promise<string> can be safely removed when using marked() and marked.parse() with respective config object
  • No breaking change for library mutating default options

Result

No regression, no behavior change

Contributor

  • Test(s) exist to ensure functionality and minimize regression (if no tests added, list tests covering this PR); or,
  • no tests required for this PR.
  • If submitting new feature, it has been documented in the appropriate places.

Committer

In most cases, this should be a different person than the contributor.

Copy link

vercel bot commented Dec 1, 2023

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
marked-website ✅ Ready (Inspect) Visit Preview 💬 Add feedback Mar 11, 2024 10:48am

src/Instance.ts Outdated

// Show warning if an extension set async to true but the parse was called with async: false
if (this.defaults.async === true && origOpt.async === false) {
if (isAsyncOptions(this.defaults) && isSyncOptions(origOpt)) {
if (!opt.silent) {
console.warn('marked(): The async option was set to true by an extension. The async: false option sent to parse will be ignored.');
Copy link
Author

@Yimiprod Yimiprod Dec 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question for maintainer: could this warning not be silent ?
Because as far as i know #3103 tried to fix the type to avoid unexepected Promises when a library change the default.async to true.

With this PR, the type returned by marked() and marked.parse() will have miss if a library change the default.async and user should have to be warned about it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The silent option is for when they want things to fail instead of get warnings. If they want to see the error they can just set the silent option to false.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is what #3103 tried to fix. It looks like this PR breaks that again. I think we still want correct types even if people need to add as string.

Copy link
Author

@Yimiprod Yimiprod Dec 4, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well this PR doesn't break it pre see, it fixes the default typing based on default behavior (sending Promise if async is set to true).
So if the user want a fail instead of warning when a library modify the default, it's pretty ok ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So you are suggesting throwing an error here instead of logging a massage? I think we could do that. This would need to be a breaking change.

@Yimiprod
Copy link
Author

Yimiprod commented Dec 4, 2023

Has it as been said in #3103 there's no way to know if the default async has been changed, is it the responsibility of marked to know that the default behavior has been changed from an external library ?

The idea of this PR is to have a known return type based on the passed configuration which #3103 kinda broke by forcing to user to use as string even if we don't use a library that modify the default configuration.

With this PR:

(async() => {
  const md = '# foobar';
  const syncMarked: string = marked(md, { async: false }); // will work at compile time, but break at runtime if default has been changed
  const asyncMarked: string = marked(md, { async: true }); // will break at compile time since return type will be `Promise<String>`

  const syncMarkedParse: string = marked.parse(md, { async: false }); // will work at compile time, but break at runtime if default has been changed
  const asyncMarkedParse: string = marked.parse(md, { async: true }); // will break at compile time since return type will be `Promise<String>`

  const awaitedDefaultMarked: string = await marked(md); // will not break at compile time, will not break at runtime, even if default has been changed
  const awaitedDefaultMarkedParse: string = await marked.parse(md); // will not break at compile time, will not break at runtime, even if default has been changed
})();

Maybe when changing type to:

function marked(src: string, options: MarkedAsyncOptions): Promise<string>;
function marked(src: string, options: MarkedSyncOptions): string;
function marked(src: string, options?: MarkedOptions & { async?: undefined }): Promise<string> | string;
//or
function marked(src: string, options?: Exclude<MarkedOptions, 'async'>): Promise<string> | string;

But that mean introducing a breaking change here:
https://github.com/markedjs/marked/blob/master/src/Instance.ts#L257

- if (isAsyncOptions(this.defaults) && isSyncOptions(origOpt)) {
+ if (isAsyncOptions(this.defaults) && typeof origOpt.async === 'undefined') { 

Which mean, fallback to default option, even overloaded by library, only if the developper does not have explicitly asked for an expected behavior.

@UziTech
Copy link
Member

UziTech commented Dec 4, 2023

Before #3103 and if we merge this PR the type retuned be marked.parse could be wrong. If you are ok with not knowing the correct return type, just don't use typescript.

What is the difference from a user adding marked.parse(markdown, { async: false}) and marked.parse(markdown) as string? I think the second one is better since it is explicit what you are doing.

@Yimiprod
Copy link
Author

Yimiprod commented Dec 4, 2023

The second one will break at runtime if a library change the default config and dev not knowing it will don't understand why since they force casted a type to the output.

@UziTech
Copy link
Member

UziTech commented Dec 4, 2023

The first one will also break at runtime with this PR and before #3103

@UziTech
Copy link
Member

UziTech commented Dec 4, 2023

So I think the only solution to not break at runtime if the developer doesn't know if they added an async extension is to leave it the way it is and await marked.parse

@UziTech
Copy link
Member

UziTech commented Dec 4, 2023

If the developer does know that they didn't add an async extension than as string seems to be the cleanest solution to me.

@Yimiprod
Copy link
Author

Yimiprod commented Dec 4, 2023

True that, that's why suggested adding a breaking change in my previous comment, making the behavior more explicit:
If the developper explicitly pass async: true returning Promise or async: false returning string marked will behave as expected.
Else if nothing is set, return Promise or string, based on default.

I don't know what impact it will have for library using marked, but since it's a breaking change, this will change the major version of marked, this way preventing update for user prefixing their package.json version with at least ^ ?

Edit: i'm eager to create a new PR for that, including documentation update, since it was also a point in #3103 by this comment #3103 (comment) about two entry points.

@vzakharov
Copy link

vzakharov commented Feb 8, 2024

Can you please merge this? Having .parse return | Promise when it definitely isn’t one is such a bugger.

For the time being, if anyone’s as bothered as I am, here’s a helper method you can use:

import { MarkedOptions, marked } from "marked";

export function parseMarkdown(markdown: string, options: Omit<MarkedOptions, 'async'> = {}) {
  const parsed = marked.parse(markdown, options);
  if ( typeof parsed === 'string' ) return parsed;
  throw new Error(`Expected marked to return a string, but it returned ${parsed}`);
};

@UziTech
Copy link
Member

UziTech commented Feb 10, 2024

If this was complete we could merge it in the next major version.

A couple of things that need to be fixed:

  • throw an error instead of warn when async: false is used with an async extension.
  • test new Marked().parse(md, { async: true/false }) returns the correct type.
  • fix merge conflicts

@Yimiprod
Copy link
Author

I'll try to find some time to fix it as soon as possible 👍

@Yimiprod
Copy link
Author

@UziTech added a new commit that replace the warning with a throw, and adding tests reflecting this changes.
The commit is flagged as breaking change (but i can remove it if you want).

src/Instance.ts Outdated
if (!opt.silent) {
console.warn('marked(): The async option was set to true by an extension. The async: false option sent to parse will be ignored.');
throw new Error('marked(): The async option was set to true by an extension. The async: false option sent to parse will be ignored.');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you use the throwError function instead of throwing the error. Similar to other errors in this function. You will have the move the creation of the function above this if statement.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the hiatus, just fixed this point, for the second point i'll try to figure out how to do it, since it need to modify typing in the .use() function to output the correct instance type.

const defaultMarked: string = marked(md);
// as string can be used if no extensions set `async: true`
const stringMarked: string = marked(md) as string;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Marked calls that don't specify an async option should be string | Promise<string> since they could be either and they won't throw an error.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since they could be either

Just curious, how’s that? If no async is specified, it defaults to false, right?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If no async is specified it should default to whatever async is set by extensions.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const syncMarked = new Marked({ async: false });
syncMarked.parse(md) // this will be a string 

const asyncMarked = new Marked({ async: true });
asyncMarked.parse(md) // this will be a Promise<string>

Copy link
Author

@Yimiprod Yimiprod Feb 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe there's a way to have typed returned instance ?
the default instance, without any modification, will be synch, right ?

const marked = new Marked();
marked.parse(md) // this will be a string if global config has not be mutated by an external library

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe. Typescript would have to run your application to see what the return type is.

Yes default without any extensions is sync

src/Instance.ts Outdated
opt.async = true;
if (isAsyncOptions(this.defaults) && isSyncOptions(origOpt)) {
// Throw an error if an extension set async to true but the parse was called with async: false
return throwError(new Error('marked(): The async option was set to true by an extension. The async: false option sent to parse will be ignored.'));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message should be:

marked(): The async option was set to true by an extension. Remove the async: false option to continue.

}

export function isSyncOptions(options: MarkedOptions): options is MarkedSyncOptions {
return options.async === false;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be false or undefined? Or maybe != true?

Copy link
Author

@Yimiprod Yimiprod Mar 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pushed something like this:

return !isAsyncOptions(options);

It fail a lot of tests since it introduce a breaking change, i have to dive in to understand why.

…ith async at false

BREAKING CHANGE: parser now throw an error when `defaultConfig` is modified by an extension to make the returned value a Promise and the we call parser with `async` option at false.
It can be skipped with `silent` to true, making it still working for library that use it to modify the config globally but still calling each instance with the option `async` at false.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants