Do Object.seal and .freeze deliver different results with regard to the return type in Typescript?

Viewed 57

I want to protect the object content of a constant from being changed at runtime. This affects both the change in the object structure and the content of the object. The method of choice here is Object.freeze.

interface iRO<T> {
    readonly [key: string]: T | null;
}

const nc: iRO<HTMLElement> = {
    main: document.querySelector("body"),
    aside: document.querySelector("body > aside"),
}

const go: iRO<HTMLElement> = Object.seal({
    main: document.querySelector("body"),
    aside: document.querySelector("body > aside"),
})

const nogo: iRO<HTMLElement> = Object.freeze({
    main: document.querySelector("body"),
    aside: document.querySelector("body > aside"),
})

As an example, I have given three constants with the same content. With the constants "nc" and "go" the compilation works fine. The compiler complains at "nogo":

»Type 'Readonly<{ main: HTMLBodyElement | null; aside: Element | null; }>' is not assignable to type 'iRO'. Property 'aside' is incompatible with index signature. Type 'Element | null' is not assignable to type 'HTMLElement | null'. Type 'Element' is missing the following properties from type 'HTMLElement': accessKey, accessKeyLabel, autocapitalize, dir, and 107 more.«

I do not understand that. Object.freeze should just as little influence the assigned type as its little brother Object.seal?

Here's a Typescript Playground

2 Answers

I think that document.querySelector("body") returns a type HTMLBodyElement which extends HTMLElement.
But document.querySelector("body > aside") doesn't return HTMLElement but Element and therefore is just only the subinterface.

And so if using iRO<HTMLElement> you expect that all keys at least extends from HTMLElement.
When using a normal object or Object.seal, you doing a smart type cast, but using Object.freeze freezes your object and therefore it detects that your aside will only be of type Element but not HTMLElement

But you could do this:

const nogo: Readonly<iRO<HTMLElement>> = Object.freeze({
    main: document.querySelector("body"),
    aside: document.querySelector("body > aside") as HTMLElement | null,
})

Edit:

Seeing the answer from @CertainPerformance, s/he is right in better using document.querySelector<HTMLElement>("body > aside") instead of document.querySelector("body > aside") as HTMLElement | null

The problem is with how TypeScript determines the type of the object before being assigned to the variable on the left-hand side.

When you use:

const nc: iRO<HTMLElement> = {

This tells TypeScript that the object on the right must conform to iRO<HTMLElement>. That is, its values must be of type HTMLElement | null. This is OK for nc because document.querySelector("body") with the proper type argument can give such a type. One way of looking at the first version is:

const nc: iRO<HTMLElement> = {
    main: document.querySelector<HTMLElement>("body"),
    aside: document.querySelector<HTMLElement>("body > aside"),
}

This also works with Object.seal because Object.seal does not do anything, type-wise, with the argument. Its type definition is:

seal<T>(o: T): T;

But Object.freeze is different. Its type definition is:

freeze<T>(o: T): Readonly<T>;

Since it returns a new type, its T isn't free to change to satisfy generics. The type of the object passed in must be static. It's like:

const obj = {
    main: document.querySelector("body"),
    aside: document.querySelector("body > aside"),
};
const nogo: iRO<HTMLElement> = Object.freeze(obj);

But the parameter has a type of

const obj: {
    main: HTMLBodyElement | null;
    aside: Element | null;
}

and the resulting type returned by Object.freeze is

Readonly<{
    main: HTMLBodyElement | null;
    aside: Element | null;
}>

It's a new type - and an Element can't be assigned to an HTMLElement.

If Object.seal returned a new type, rather than the same type as the argument, you'd see the same problem with it as well. But since Object.seal returns the same type as its argument, it doesn't result in the same problem.

Use the generic parameter to querySelector to fix it:

const nc = Object.freeze({
    main: document.querySelector("body"),
    aside: document.querySelector<HTMLElement>("body > aside"),
})
Related