-
-
Notifications
You must be signed in to change notification settings - Fork 13
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
Return narrower Element types #23
Comments
@Caellian could you zoom out a bit. |
I have a rehype plugin that turns I enabled typescript on the preprocessor to iron out quirks, and wanted to specify type that describes structure of this new element: /**
* @typedef {object} DynamicCodeData
* @property {boolean} [noCodeblock]
* @property {{[marker: string]: boolean}} [markers]
*/
/**
* @typedef {object} DynamicCodeElement
* @property {"element"} type
* @property {"code"} tagName
* @property {DynamicCodeData & ElementData} [data]
* @property {{className: ["language-js"]}} properties
* @property {[Text]} children
*/
/**
* @typedef {object} CollapsedCodeElement
* @property {"element"} type
* @property {"details"} tagName
* @property {[
* {tagName: "summary"},
* {tagName: "pre", children: [ DynamicCodeElement ]}
* ]} children
*/
/**
* @typedef {{
* "data-exports"?: string[] | undefined,
* "data-deferred"?: boolean | undefined,
* "data-module"?: boolean | undefined,
* }} DynamicScriptProperties
*/
/**
* @typedef {object} DynamicScriptElement
* @property {"element"} type
* @property {"dynamic-script"} tagName
* @property {DynamicScriptProperties & Properties} properties
* @property {[DynamicCodeElement | CollapsedCodeElement] & ElementContent[]} children
*/ However, I can't assign results of h("dynamic-script", {
"data-module": true
}, dynamicCodeElement) can't be made into
This forces me to cast This is the result of type widening in TypeScript generics. Generally, some types are erased into their wider representations because generally that's the expected behavior. However, as hast deals with ast, the opposite is the case and narrowest type definitions should be preserved to allow users to specify document schema in type signatures. I ended up writing my own version without support for selectors: // https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/71296
type Data = HASTElementData | Record<string, unknown>;
type Element = HASTElement & { data?: Data | undefined };
type ElementContent = HASTElementContent | Element;
/**
* Single text child node.
*/
type SingleText<C> = [{ value: C } & Text] & ElementContent[];
/**
* Type of children that avoids widening by making child type a generic.
*/
type Children<C extends ElementContent[]> = readonly [...C] & ElementContent[];
interface HElementFn {
<T extends string>(tagName: T): { tagName: T } & Element;
<T extends string, const P extends Properties>(
tagName: T,
properties: P
): { tagName: T; properties: P } & Element;
<T extends string, const C extends Children<TC>, TC extends ElementContent[]>(
tagName: T,
children: C
): { tagName: T; children: C } & Element;
<T extends string, const C extends string>(
tagName: T,
innerText: C
): {
tagName: T;
children: SingleText<C>;
} & Element;
<
T extends string,
const P extends Properties,
const C extends Children<TC>,
TC extends ElementContent[],
>(
tagName: T,
properties: P,
children: C
): { tagName: T; properties: P; children: C } & Element;
<T extends string, const P extends Properties, const C extends string>(
tagName: T,
properties: P,
innerText: C
): { tagName: T; properties: P; children: SingleText<C> } & Element;
<T extends string, const P extends Properties, const D extends Data>(
tagName: T,
properties: P,
data: D
): { tagName: T; properties: P; data: D } & Element;
<
T extends string,
const C extends Children<TC>,
const D extends Data,
TC extends ElementContent[],
>(
tagName: T,
children: C,
data: D
): { tagName: T; children: C; data: D } & Element;
<T extends string, const C extends string, const D extends Data>(
tagName: T,
innerText: C,
data: D
): { tagName: T; children: SingleText<C>; data: D } & Element;
<
T extends string,
const P extends Properties,
const C extends Children<TC>,
const D extends Data,
TC extends ElementContent[],
>(
tagName: T,
properties: P,
children: C,
data: D
): { tagName: T; properties: P; children: C; data: D } & Element;
<
T extends string,
const P extends Properties,
const C extends string,
const D extends Data,
>(
tagName: T,
properties: P,
innerText: C,
data: D
): {
tagName: T;
properties: P;
children: SingleText<C>;
data: D;
} & Element;
}
/**
* A type safe version of `hastscript` `h` function. Doesn't work with arbitrary
* selectors.
*/
export const hElement: HElementFn = ((tagName: string, ...args: any) => {
const result = {
type: "element",
tagName,
};
let order = [
{
key: "properties",
check: {},
default: {},
},
{
key: "children",
check: [],
default: [],
},
{
key: "data",
check: {},
default: undefined,
},
];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
let current = order.shift();
if (current == null) {
throw new TypeError(`invalid argument #${i + 1} provided: ${arg}`);
}
if (
typeof arg === typeof current.check &&
Array.isArray(arg) === Array.isArray(current.check)
) {
result[current.key] = arg;
} else if (current.key === "children" && typeof arg === "string") {
result[current.key] = [hText(arg)];
} else {
result[current.key] = current.default;
i--;
}
}
return result;
}) as undefined as HElementFn;
export function hText(value: string): Text {
return { type: "text", value };
} I didn't exactly follow I'm not very proficient in TS, but it seems doable and would allow end-users to describe AST structure in types as I initially wanted. |
You can use an if-statement:
More complex types have some benefits. They also have some downsides. It’s a trade off. To choose between trade offs, we’d need arguments. So: what are your arguments?
Indeed, it’s not that useful? So, why do you want this?
Right, things could be possible, but the types here are rather complex already. So, to choose whether more complexity is nice, I’d worry about the arguments
Don’t. Use the types we provide. Use Our types do not match your types. That will not change with this one change.
You can do that in JS too. You can do type casts in JS! |
I wasn't clear, it tells TS "it's this, or any other Element". This completely removes the need for all
While this is an option - why? Wouldn't a design that moves type checking into compile time be better? It doesn't impose any additional requirements for users using
Hmmm... I did inline casting incorrectly so it didn't work. Anyway, now that I've figured it out, to illustrate, here's a diff: --- rehype-dynamicScripts-suggestion.js
+++ rehype-dynamicScripts-hastscript.js
@@ -197,17 +163,17 @@
})()
);
- let href = rebasePath(source.properties.src, options.targetLocation || "/");
+ let href = rebasePath(/** @type {string} */ (source.properties.src), options.targetLocation || "/");
let note = "remote JS";
if (options.isModule) {
note = "remote ESM";
}
# these are mostly the same, ignore differences here, there would be none if implemented
- target.children.push(
- hEl("span", { className: "status" }, note),
- hEl("a", { className: "path", href }, href)
- );
+ target.children.push(
+ h("span.status", note),
+ h("a.path", { href }, href)
+ );
return null;
}
@@ -250,40 +216,26 @@
options.isModule
);
- /** @type {DynamicCodeElement} */
- const exec = hEl("code", {
- className: ["language-js"],
- }, code, {
- noCodeblock: true,
- }
- );
- /** @type {CollapsedCodeElement} */
- let detailsEl = hEl("details", [
- hEl("summary", "source"),
- hEl("pre", [exec]),
- ]);
-
- if (source.properties.className?.includes("show")) {
- /** @type {DynamicCodeElement} */
- let codeEl = hEl("code", { className: ["language-js"] }, code);
+ const exec = h("code.language-js");
+ exec.data = {
+ // @ts-ignore NOT IN ElementData
+ noCodeblock: true,
+ };
+ let detailsEl = h("details", [
+ h("summary", "source"),
+ h("pre", [exec])
+ ]);
+
+ let className = /** @type {string[]?} */ (source.properties.className);
+ if (className?.includes("show")) {
+ let codeEl = h("code.language-js", code);
if (options.deferred) {
codeEl.data = {
+ // @ts-ignore NOT IN ElementData
markers: {
deferred: true,
},
};
}
@@ -331,11 +283,7 @@
let deferred = el.properties.defer == true;
let isModule = el.properties.type === "module";
# the only part that's worse off, but that's bc of my design decisions
- /** @type {DynamicScriptElement} */
- // @ts-ignore the type will be valid once handler is called
- const target = hEl("dynamic-script", {
+ const target = h("dynamic-script", {
"data-deferred": deferred ? true : undefined,
"data-module": isModule ? true : undefined,
});
@@ -361,8 +309,14 @@
}
let child = target.children[0];
# example of consumption differences really drives the point home.
# often the case when elements are generated in multiple passes over ast
- if (child.tagName == "code" && child.data.markers["data-deferred"]) {
- // Special handling
+ if (
+ child.type == "element" &&
+ child.tagName == "code"
+ ) {
+ let data = /** @type {Record<string, boolean>} */ (child.data);
+ if (data.markers["data-deferred"]) {
+ // Special handling
+ }
}
parent.children.splice(i, 1, target); Ignore that variable declaration types take up additional lines (in TS they'd be inline), and ignore that my Yes, declaring schema in JSDoc adds verbosity, but that's part of intentional design. What's important to note is that all those type casts circumvent TS and aren't type checked/safe. The only way to guarantee correctness is having large if statements that pin down requirements, but that adds runtime overhead.
I don't like this either, but IMO I'd prefer hastscript to deal with that than rolling my own 😄 . The types aren't really that complex, just repetitive because of all the different invocations (and I added Data). It's pretty concise without Data and my implementation: // index.d.ts
type Properties = import('hast').Properties;
// https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/71296
type Data = import("hast").ElementData | Record<string, unknown>;
type HASTElement = import("hast").Element & { data?: Data | undefined };
type ElementContent = import("hast").ElementContent | Element;
/**
* Single text child node.
*/
type SingleText<C> = [{ value: C } & Text] & ElementContent[];
/**
* Type of children that avoids widening by making child type a generic.
*/
type Children<C extends ElementContent[]> = readonly [...C] & ElementContent[];
interface HElementFn {
<T extends string>(tagName: T): { tagName: T } & Element;
<T extends string, const P extends Properties>(
tagName: T,
properties: P
): { tagName: T; properties: P } & HASTElement;
<T extends string, const C extends Children<TC>, TC extends ElementContent[]>(
tagName: T,
children: C
): { tagName: T; children: C } & HASTElement;
<T extends string, const C extends string>(
tagName: T,
innerText: C
): {
tagName: T;
children: SingleText<C>;
} & HASTElement;
<
T extends string,
const P extends Properties,
const C extends Children<TC>,
TC extends ElementContent[],
>(
tagName: T,
properties: P,
children: C
): { tagName: T; properties: P; children: C } & HASTElement;
<T extends string, const P extends Properties, const C extends string>(
tagName: T,
properties: P,
innerText: C
): { tagName: T; properties: P; children: SingleText<C> } & HASTElement;
}
declare const h: HElementFn; In short, narrower types are better IMO because they will cause errors or require checks when users try to access undefined properties, but should also be completely compatible with all existing code. |
We can have a discussion about fancy types, and their trade offs. But before we can have that: you have some rather weird types which can be improved. As a user you should barely need to write I’d appreciate it if you ask questions about the problems you run into, instead of asking questions about what you think the solution is (https://xyproblem.info). |
Right, but it's not xyproblem because the diff from my previous comment shows that I solved both X and Y problems. I'm making this suggestion because IMO this looks nicer: - let href = rebasePath(/** @type {string} */ (source.properties.src), options.targetLocation || "/");
+ let href = rebasePath(source.properties.src, options.targetLocation || "/"); and provides much better experience when one expects certain node types: - if (
- child.type == "element" &&
- child.tagName == "code"
- ) {
- let data = /** @type {Record<string, boolean>} */ (child.data);
- if (data.markers["data-deferred"]) {
- // Special handling
- }
+ if (child.tagName == "code" && child.data.markers["data-deferred"]) {
+ // Special handling
} I mean, it's not 100% necessary because in most cases people don't create very complicated ASTs dynamically so |
That is question better posed to you, why? If your content is fully static why use this library and add overhead? You could write the static hast, or even better, serve plain HTML with no pre-processing.
Adding a bunch of type overhead and complexity for a use case that doesn't make sense, makes the experience worse, not better. Perhaps you have a good reason, but you haven't shared it. Which is why @wooorm is highlighting the XY Problem.
The existing code can already do this, the if statement @wooorm proposed in #23 (comment) does the exact same thing in terms of narrowing the type. |
I've already answered, provided diffs, pros and cons. But to summarize: Pros:
You're mistaken, types are rarely I'm suggesting that if user provides, for instance, children to hast, then it should return
It's not. It just has a schema that's inherits from hast. I am processing markdown, but dealing with components that have specific requirements with invariants that cause runtime crashes in frontend if they're not upheld. I also, explained I'm doing processing on content, but that has nothing to do with the initial suggestion and I believe we're getting sidetracked.
What is type overhead? That's why I'm asking you - what exactly would make the experience worse? I have tried using both versions of code and see this as an improvement. That's why I made this suggestion.
No, I have shared all the necessary details if the initial comment, refer to the Problem section for use case and reasoning. That's runtime checking and doesn't completely utilize TS. Yes, it can infer type if enough runtime checks pin it down, but that overhead can be completely avoided if you simply forward input types back into output. |
Sorry, but your types are messed up; solve those first. You do not need You previously said you are a bit new at TS. TS is complex and annoying. ASTs are too. Could you take a look at the source of |
My use of types has little to do with this suggestion. I provided most code as an example, and I agree it's far from ideal. I did apply some of the suggestions (? on properties), and updated my previous comments. But I'm not suggesting anything beyond adding narrower type definitions for
It's not. If Yes, augmenting the types allows elements to have custom children and data, but doesn't affect the result type of Anyway, here's the latest example version which assumes proper augmentation, and still handles passed values properly: import { Element, ElementContent, ElementData, Properties, Text } from "hast";
/**
* Single text child node.
*/
type SingleText<C> = [{ value: C } & Text, ...ElementContent[]] &
ElementContent[];
/**
* Type of children that avoids widening.
*/
type Children<C extends ElementContent[]> = readonly [...C] & ElementContent[];
interface HElementFn {
<T extends string>(tagName: T): { tagName: T } & Element;
<T extends string, P extends Properties>(
tagName: T,
properties: P
): { tagName: T; properties: P } & Element;
<T extends string, const C extends Children<TC>, TC extends ElementContent[]>(
tagName: T,
children: C
): { tagName: T; children: C } & Element;
<T extends string, const C extends string>(
tagName: T,
innerText: C
): {
tagName: T;
children: SingleText<C>;
} & Element;
<
T extends string,
P extends Properties,
const C extends Children<TC>,
TC extends ElementContent[],
>(
tagName: T,
properties: P,
children: C
): { tagName: T; properties: P; children: C } & Element;
<T extends string, P extends Properties, const C extends string>(
tagName: T,
properties: P,
innerText: C
): { tagName: T; properties: P; children: SingleText<C> } & Element;
<T extends string, P extends Properties, D extends ElementData>(
tagName: T,
properties: P,
data: D
): { tagName: T; properties: P; data: D } & Element;
<
T extends string,
const C extends Children<TC>,
D extends ElementData,
TC extends ElementContent[],
>(
tagName: T,
children: C,
data: D
): { tagName: T; children: C; data: D } & Element;
<T extends string, const C extends string, D extends ElementData>(
tagName: T,
innerText: C,
data: D
): { tagName: T; children: SingleText<C>; data: D } & Element;
<
T extends string,
P extends Properties,
const C extends Children<TC>,
D extends ElementData,
TC extends ElementContent[],
>(
tagName: T,
properties: P,
children: C,
data: D
): { tagName: T; properties: P; children: C; data: D } & Element;
<
T extends string,
P extends Properties,
const C extends string,
D extends ElementData,
>(
tagName: T,
properties: P,
innerText: C,
data: D
): {
tagName: T;
properties: P;
children: SingleText<C>;
data: D;
} & Element;
}
/**
* A type safe version of `hastscript` `h` function. Doesn't work with arbitrary
* selectors.
*/
export const hElement: HElementFn = ((tagName: string, ...args: any) => {
const result = {
type: "element",
tagName,
};
let order = [
{
key: "properties",
check: {},
default: {},
},
{
key: "children",
check: [],
default: [],
},
{
key: "data",
check: {},
default: undefined,
},
];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
let current = order.shift();
if (current == null) {
throw new TypeError(`invalid argument #${i + 1} provided: ${arg}`);
}
if (
typeof arg === typeof current.check &&
Array.isArray(arg) === Array.isArray(current.check)
) {
result[current.key] = arg;
} else if (current.key === "children" && typeof arg === "string") {
result[current.key] = [hText(arg)];
} else {
result[current.key] = current.default;
i--;
}
}
for (const other of order) {
if (other.default == null) {
continue;
}
result[other.key] = other.default;
}
return result;
}) as undefined as HElementFn;
export function hText(value: string): Text {
return { type: "text", value };
} I also fixed an error with the custom function implementation, but that's irrelevant as you'd use existing |
Indeed: has to do with XY. I maintain code that has good reasons to exist. You have problems with your types because your types have problems, not because of tag names. The solution is to solve your problems. Not tag names.
Post the code. Remove everything that isn’t needed. Demonstrate your problem as simple as possible. Make the clearest, smallest, case for this |
Initial checklist
Problem
Assuming there exists some type definition for:
The
h
function currently returnsElement
which doesn't satisfytagName
restriction. This requires manual casting and/orts-ignore
in TS or JS code with (TS backed) type checking. It would be nice if the function returned{tagName: selector} & Element
,even though that still doesn't guarantee.properties
and other requirements to matchCurrent solutions
While toying around with it locally, I saw that this is the ~best that can be done given that selectors can be composed tag expressions with classes and what not. So currently, the type is always
Element
because of that.Proposed solutions
Soon after, I realized that in (my) case where tag name is a simple lowercase HTML tag, there could exist a type
HTMLName
which covers all of them and an additional@override
for returnedh
function which specializes return type based on stricterHTMLName
selector type to satisfy:Now, I checked into used
hast-util-parse-selector
and it seems to return proper types, so it's hastscript that erases this information.The text was updated successfully, but these errors were encountered: