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.

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