Skip to content

Declaration emit drops readonly from a nested object literal under a const type parameter (7.0.2, 7.1.0-dev; correct in 6.0.3) #64059

Description

🔎 Search Terms

declaration emit, readonly, const type parameter, freshness, tsgo, TypeScript 7

🕗 Version & Regression Information

version result
typescript@5.9.3 correct
typescript@6.0.3 correct
typescript@7.0.2 drops readonly
typescript@7.1.0-dev.20260826.1 drops readonly

This is a regression introduced in the 7.x (native/Go) line. 6.0.3 — the last JavaScript implementation — is correct, so it is not an intentional 6.0 language change.

Tested on macOS arm64. Not reproducible on the playground, which does not yet run the 7.x compiler.

💻 Code

const leaf = { a: 1 } as const;
declare function define<const T>(x: T): T;

export const twoRefs = define({ properties: { one: leaf, two: leaf } });
tsc --declaration --emitDeclarationOnly --target ES2022 --strict

🙂 Expected behavior

readonly is preserved on the nested properties, as in 5.9.3 and 6.0.3:

export declare const twoRefs: {
    readonly properties: {
        readonly one: { readonly a: 1 };
        readonly two: { readonly a: 1 };
    };
};

🙁 Actual behavior

readonly is dropped from one and two in 7.0.2 and 7.1.0-dev:

export declare const twoRefs: {
    readonly properties: {
        one: { readonly a: 1 };      // readonly lost
        two: { readonly a: 1 };      // readonly lost
    };
};

The emitter disagrees with the checker

This is what makes it more than a cosmetic difference. Checking the source, 7.0.2 correctly treats the property as readonly:

twoRefs.properties.one = leaf;
// error TS2540: Cannot assign to 'one' because it is a read-only property.

But that same assignment compiles clean against the .d.ts that 7.0.2 just emitted. Declaration emit is therefore not round-trip-faithful to the compiler's own type system, and consumers reading the emitted declarations silently lose the modifier across a package boundary.

When it happens

Narrowed with a depth × composition matrix:

  • Only nested literals. The top-level argument literal always emits correctly; the problem starts at depth ≥ 1.
  • Only when every property value is non-fresh (an identifier reference or a call result). Adding a single fresh member — object literal or primitive — makes the whole literal emit correctly:
    define({ properties: { one: leaf, two: { b: 2 } } })   // correct in all versions
  • It propagates upward. In define({ p: { q: { one: leaf } } }), both q and one lose readonly, even though q's value is a fresh literal.
  • Specific to const type parameters. The as const equivalent is correct in all versions:
    const asConstForm = { properties: { one: leaf, two: leaf } } as const;   // correct

Additional information

Found in a monorepo using a schema-builder helper of the form:

function defineObjectSchema<const Schema extends JsonSchema & { readonly type: "object" }>(
  schema: Schema,
): Schema { return schema; }

Call sites whose nested properties object contains only references to other schema constants lose readonly on those properties in the published declarations — 18 modifiers across two .d.ts files. Nothing there mutates the schemas so nothing broke, but consumers of the package do lose a guard the source intends.

I could not file this on microsoft/typescript-go, which restricts issue creation to collaborators. Happy to move it if that is the preferred home.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

BugA bug in TypeScriptDomain: Declaration EmitThe issue relates to the emission of d.ts files

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions