Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to create interface for a function which creates a nested element structure in typescript?

I am new to typescript. I am implementing a previously created function using javascript. The function takes in an object. Which have following properties

  • tag: which will be a string.
  • children: which is array of the same interface(i.e ceProps as shown below);
  • style: which will be object containing styles like (color, fontSize etc.)
  • Any other keys could be added to this object.(Like innerHTML, src etc.)

Here is by code.

interface Style { 
    [key : string ]: string;
}

interface ceProps {
    tag: string;
    style?: Style;
    children?: ceProps[];
    [key : string]: string;
}

const ce = ({tag, children, style, ...rest } : ceProps) => {
    const element = document.createElement(tag);

    //Adding properties
    for(let prop in rest){
        element[prop] = rest[prop];
    }

    //Adding children
    if(children){
        for(let child of children){
            element.appendChild(ce(child))     
        }
    }

    //Adding styles
    if(style){
        for(let prop in style){
            element.style[prop] = style[prop];
        }
    }
    return element;
}

It shows error on the style and children

Property 'style' of type 'Style | undefined' is not assignable to string index type 'string'.ts(2411)

Property 'children' of type 'ceProps[] | undefined' is not assignable to string index type 'string'.ts(2411)

There is one more error one the line element[prop] = rest[prop]; and same error on element.style[prop] = style[prop];

Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'HTMLElement'. No index signature with a parameter of type 'string' was found on type 'HTMLElement'

Kindly explain each problem and its fix.

like image 788
Maheer Ali Avatar asked Jan 25 '23 11:01

Maheer Ali


1 Answers

Answering Your Questions

Assignability with index properties

Yes, interfaces will not let you define both a string-index property, and properties using specific strings that are defined differently. You can get around this using an intersection type:

type ceProps =
    & {
        tag: string;
        style?: Style;
        children?: ceProps[];
    }
    & {
        [key: string]: string;
    };

This tells Typescript that tag will always be there and always be a string, style may or may not be there, but will be a Style when it is there, and children may or may not be there, but will be a ceProps[] when it is there. Any other property may also exist, and will always be a string.

Indexing HTMLElement

The problem is that you specified that ceProps could include any string as a property, but HTMLElement does not have any string ever as a property, it has particular properties defined for it.

You can escape out of Typescript’s checking here by casting element as any, or element.style as any, like so:

    //Adding properties
    for (const prop in rest) {
        (element as any)[prop] = rest[prop];
    }
    if (style) {
        for (const prop in style) {
            (element.style as any)[prop] = style[prop];
        }
    }

However, this is not type-safe. Nothing is checking that the properties in your ceProps is actually a property that the element you created can have or use. HTML is pretty forgiving—most of the time the property will just be silently ignored—but that can be even more hair-pulling than a crash is, because you’ll have no indication what’s wrong.

In general, you should be extremely cautious about using any. Sometimes you have to, but it should always make you uncomfortable.

Improving the Type Safety

This will let you compile your existing code as Typescript, and it will provide at least a little bit of type-safety. Typescript can do much, much better though.

CSSStyleDeclaration

The lib.dom.d.ts file that comes with Typescript has loads of definitions for all kinds of things in HTML and native Javascript. One of them is CSSStyleDeclaration, a type used for styling HTML elements. Use this instead of your own Style declaration:

type ceProps =
    & {
        tag: string;
        style?: CSSStyleDeclaration;
        children?: ceProps[];
    }
    & {
        [key: string]: string;
    };

When you do this, you no longer need to cast element.style with (element.style as any)—you can just use this:

    //Adding styles
    if (style) {
        for (const prop in style) {
            element.style[prop] = style[prop];
        }
    }

This works because now Typescript knows that your style is the same kind of object as element.style, so this will work out correctly. As a bonus, now when you create your ceProps in the first place, you’ll get an error if you use a bad property—win-win.

Generic Types

The definition of ceProps will allow you to define a structure that will work with ce to create any element. But a potentially better solution here is to make this generic. That way we can track which tag is associated with a particular instance of ceProps.

type CeProps<Tag extends string = string> =
    & {
        tag: Tag;
        style?: CSSStyleDeclaration;
        children?: CeProps[];
    }
    & {
        [key: string]: string;
    };

(I renamed ceProps to CeProps to be more in-line with typical Typescript naming style, though of course your project is welcome to use its own style.)

The angle brackets indicate generic type parameters, here Tag. Having Tag extends string means that Tag is constrained to be a string—something like CeProps<number> will be an error. The = string part is a default parameter—if we write CeProps without angle brackets, we mean CeProps<string>, that is, any string.

The advantage of this is that Typescript supports string literal types, which extend string. So you could use CeProps<"a">, and then we would know that tag is not just any string, but "a" specifically.

So then we have the ability to indicate what tag we’re talking about. For example:

const props: CeProps<"a"> = { tag: "a", href: "test" };

If you were to write tag: "b" here, you would get an error—Typescript will require that this be an "a". You could write a function that takes only a specific CeProps maybe, and so on.

Typescript can also infer this correctly if you use the as const keyword:

const props = { tag: "a" } as const;

Typescript will understand that this props variable is a CeProps<"a"> value. (Actually, technically, it will understand it as a { tag: "a"; } type, but that is compatible with CeProps<"a"> and can be passed to a function expecting that, for example.)

Finally, if you are interested in writing a function that can only take CeProps for particular tags, but not just one tag, you can use a union type, which is indicated with a |:

function takesBoldOrItalics(props: CeProps<"b" | "i">): void {

You could call this function with const aBold: CeProps<"b"> = { tag: "b" };, or with const anItalic = { tag: "i" } as const;, or just call it directly like takesBoldOrItalics({ tag: "b" });. But if you try to call it with { tag: "a" } you’ll get an error.

Constraining things to keyof HTMLElementTagNameMap

Another powerful tool in lib.dom.d.ts is HTMLElementTagNameMap, which gives the specific HTMLElement for each possible HTML tag string. It looks like this:

interface HTMLElementTagNameMap {
    "a": HTMLAnchorElement;
    "abbr": HTMLElement;
    "address": HTMLElement;
    "applet": HTMLAppletElement;
    "area": HTMLAreaElement;
    // ...
}

(Copied from lib.dom.d.ts)

This is used by lib.dom.d.ts to type createElement itself, for example:

createElement<K extends keyof HTMLElementTagNameMap>(
    tagName: K,
    options?: ElementCreationOptions,
): HTMLElementTagNameMap[K];

(I copied this from lib.dom.d.ts and added some line breaks for readability.)

Notice the <K extends keyof HTMLElementTagNameMap> part here. As with our <Tag extends string> on CeProps, this indicates a type parameter K with a constraint. So K must be some kind of keyof HTMLElementTagNameMap. If you are unfamiliar, keyof indicates the “keys of” some type—the property names. So keyof { foo: number; bar: number; } is "foo" | "bar". And keyof HTMLElementTagNameMap is "a" | "abbr" | "address" | "applet" | "area" | ...—a union of all of the potential HTML tag names (at least as of the last update to lib.dom.d.ts). That means createElement is requiring tag to be one of those strings (there are other overloads for it that handle other strings and just returns an HTMLElement).

We can leverage this same functionality in our CeProps:

type CeProps<Tag extends keyof HTMLElementTagNameMap = keyof HTMLElementTagNameMap> =
    & {
        tag: Tag;
        style?: CSSStyleDeclaration;
        children?: CeProps[];
    }
    & {
        [key: string]: string;
    };

Now if we wrote ce({ tag: "image" }) instead of ce({ tag: "img" }) we would get an error instead of it being silently accepted and then not working correctly.

Typing the rest correctly

If we use Tag extends keyof HTMLElementTagNameMap, we can type the “rest” properties more precisely, which protects you from making mistakes as well as limits the amount of casting you need to do inside ce.

To use it, I’ve updated CeProps like this:

interface MinimalCeProps<Tag extends keyof HTMLElementTagNameMap> {
    tag: Tag;
    style?: CSSStyleDeclaration;
    children?: CeProps[];
}
type CeProps<Tag extends keyof HTMLElementTagNameMap = keyof HTMLElementTagNameMap> =
    & MinimalCeProps<Tag>
    & Partial<Omit<HTMLElementTagNameMap[Tag], keyof MinimalCeProps<Tag>>>;

I split it up into two parts, MinimalCeProps for the parts you want to always appear, and then the full CeProps which produces the intersection of that type with Partial<Omit<HTMLElementTagNameMap[Tag], keyof MinimalCeProps<Tag>>>. That’s a mouthful, but we’ll break it down in a moment.

Now then, we have that business with Partial and Omit. To break it down,

  • HTMLElementTagNameMap[Tag] is the HTML element corresponding to Tag. You’ll notice this is the same type used as the return type on createElement.

  • Omit indicates that we are leaving out some properties of the type we pass in as the first parameter, as indicated by the union of string literals in the second. For example, Omit<{ foo: string; bar: number; baz: 42[]; }, "foo" | "bar"> will result in { bar: 42[]; }.

    In our case, Omit<HTMLElementTagNameMap[Tag], keyof MinimalCeProps<Tag>>, we are leaving out the properties from HTMLElementTagNameMap[Tag] that are already properties in MinimalCeProps<Tag>—namely, tag, style, and children. This is important because HTMLElementTagNameMap[Tag] is going to have some children property—and it’s not going to be CeProps[]. We could just use Omit<HTMLElementTagNameMap[Tag], "children"> but I thought it best to be thorough—we want MinimalCeProps to “win” for all those tags.

  • Partial indicates that all of the passed type’s properties should be made optional. So Partial<{ foo: number; bar: string; baz: 42[]; }> will be { foo?: number; bar?: string; baz?: 42[]; }.

    In our case, this is just to indicate that we aren’t going to pass in every property of whatever HTML element here—just the ones we’re interested in overriding.

There are two advantages to doing things this way. First of all, this prevents typo’d or mis-typed properties from being added to the CeProps. Second, it can be leveraged by ce itself to reduce the reliance on casting:

function ce<T extends keyof HTMLElementTagNameMap>(
    { tag, children, style, ...rest }: CeProps<T>,
): HTMLElementTagNameMap[T] {
    const element = window.document.createElement(tag);

    //Adding properties
    const otherProps = rest as unknown as Partial<HTMLElementTagNameMap[T]>;
    for (const prop in otherProps) {
        element[prop] = otherProps[prop]!;
    }

    //Adding children
    if (children) {
        for (const child of children) {
            element.appendChild(ce(child));
        }
    }

    //Adding styles
    if (style) {
        for (const prop in style) {
            element.style[prop] = style[prop];
        }
    }
    return element;
}

Here, element automatically gets the correct type, HTMLElementTagNameMap[T] thanks to createElement’s type declaration. Then we have to create the otherProps “dummy variable,” and sadly that requires some casting—but we can be safer than casting to any. We also need to use ! on otherProps[prop]—the ! tells Typescript that the value isn’t undefined. This is because you could create a CeProps with an explicitly undefined value, like { class: undefined }. Since that would be a weird mistake to make, it doesn’t seem worth checking against it. Properties you just leave out won’t be a problem, because they won’t appear in for (const props in otherProps).

And more importantly, the return type of ce is correctly typed—just the same way that createElement is typed. This means that if you do ce({ tag: "a" }), Typescript will know you’re getting an HTMLAnchorElement.

Conclusion: Some examples/test cases

// Literal
ce({
    tag: "a",
    href: "test",
}); // HTMLAnchorElement

// Assigned to a variable without as const
const variable = {
    tag: "a",
    href: "test",
};
ce(variable); // Argument of type '{ tag: string; href: string; }' is not assignable to parameter of type 'CeProps<...

// Assigned to a variable using as const
const asConst = {
    tag: "a",
    href: "test",
} as const;
ce(asConst); // HTMLAnchorElement

// Giving invalid href property
ce({
    tag: "a",
    href: 42,
}); // 'number' is not assignable to 'string | undefined'

// Giving invalid property
ce({
    tag: "a",
    invalid: "foo",
}); // Argument of type '{ tag: "a"; invalid: string; }' is not assignable to parameter of type 'CeProps<"a">'.
//   Object literal may only specify known properties, but 'invalid' does not exist in type 'CeProps<"a">'.
//   Did you mean to write 'oninvalid'?

// Giving invalid tag
ce({ tag: "foo" }); // Type '"foo"' is not assignable to type '"object" | "link" | "small" | ...
like image 156
KRyan Avatar answered Jan 30 '23 10:01

KRyan