🔎 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.
🔎 Search Terms
declaration emit, readonly, const type parameter, freshness, tsgo, TypeScript 7
🕗 Version & Regression Information
typescript@5.9.3typescript@6.0.3typescript@7.0.2readonlytypescript@7.1.0-dev.20260826.1readonlyThis 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
🙂 Expected behavior
readonlyis preserved on the nested properties, as in 5.9.3 and 6.0.3:🙁 Actual behavior
readonlyis dropped fromoneandtwoin 7.0.2 and 7.1.0-dev: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:
But that same assignment compiles clean against the
.d.tsthat 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:
define({ p: { q: { one: leaf } } }), bothqandonelosereadonly, even thoughq's value is a fresh literal.consttype parameters. Theas constequivalent is correct in all versions:Additional information
Found in a monorepo using a schema-builder helper of the form:
Call sites whose nested
propertiesobject contains only references to other schema constants losereadonlyon those properties in the published declarations — 18 modifiers across two.d.tsfiles. 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.