Skip to content

fix: make the Exact helper compile on TypeScript 7.1 - #1293

Merged
stephenh merged 1 commit into
stephenh:mainfrom
infodusha:fix/exact-typescript-7-1
Oct 22, 2026
Merged

stephenh merged 1 commit into
stephenh:mainfrom
infodusha:fix/exact-typescript-7-1

Conversation

@infodusha

@infodusha infodusha commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Symptom

Every generated file that uses useExactTypes (the default) fails to compile on TypeScript 7.1 (checked with 7.1.0-beta and 7.1.0-dev.20261020):

error TS2344: Type 'I[K]' does not satisfy the constraint 'P[K]'.
  Type 'I' is not assignable to type 'P'.
    'I' is only assignable to the non-distributed 'P', but 'P' has been distributed here.
error TS2536: Type 'K' cannot be used to index type 'I'.

TypeScript 5.9 and 7.0 accept the same code.

Cause

type Exact<P, I extends P> = P extends Builtin ? P
  : P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never };

The conditional distributes over P, so in the false branch P is a single union member, while the constraint I extends P still refers to the whole union. Since microsoft/TypeScript#64237 (merged for 7.1) the compiler distinguishes the distributed P from the non-distributed one, so I[K] (with K extends keyof <distributed P>) is no longer considered a valid index into I, and I[K] no longer satisfies P[K].

This is intentional rather than a beta bug: the old alias was unsound (I extends Child | undefined does not guarantee that I has the keys of Child), and that PR uses the same shape, type Issue<A, B extends A> = A extends unknown ? Show<A, B> : never, as its example of a newly reported error. microsoft/TypeScript#64673 and its fix #64307 concern a separate regression from the same change (identical generic signatures no longer relating) and do not cover this pattern; the latest 7.1 nightly still rejects it.

Fix

type Exact<P, I> = P extends Builtin ? P
  : P & { [K in keyof P as Extract<K, keyof I>]: Exact<P[K], I[K & keyof I]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never };
  • I is no longer constrained by the alias. fromPartial/create already constrain I extends Exact<DeepPartial<T>, I>, so nothing changes for callers.
  • The mapped type keeps only the keys of P that I actually has, which lets I[K & keyof I] type-check. This produces the same checks as before: for keys missing from I the old I[K] resolved to unknown, i.e. no excess-property check for that key, and P & still contributes every property. Union fields (oneof=unions), Builtins, useUnknownForValue and $type behave as before.

Alternatives that were tried and rejected: K extends keyof I ? I[K] : unknown breaks the nested Child.fromPartial(object.child) calls inside generated code (deferred conditional types no longer relate across the generic Is), and any (I & X)[K] form produces "circularly references itself" errors.

Testing

  • New type tests (@ts-expect-error for unknown properties at the top level, in nested messages, in repeated fields and inside oneof cases, plus compiling valid partials) in integration/simple, integration/oneof-unions and integration/value-unknown.
  • yarn proto2ts re-generated all integration output; yarn tsc:check and yarn test pass on TypeScript 5.9.3.
  • integration/**/*.ts and integration/**/*-test.ts type-check on [email protected] (with the removed esModuleInterop option dropped from the tsconfig). The only remaining 7.1 errors are the pre-existing new DataLoader(...) calls on a namespace import in batching-with-context and lower-case-svc-methods, which are unrelated to this change.

TypeScript 7.1 distinguishes the distributed `P` inside
`P extends Builtin ? ... : ...` from the non-distributed `P` that the
constraint `I extends P` refers to, so `I[K]` in the generated `Exact`
helper fails with TS2344/TS2536 in every file that uses fromPartial/create.

Drop the alias constraint (fromPartial/create already constrain
`I extends Exact<DeepPartial<T>, I>`) and only map the keys of `P` that
`I` actually has (`as Extract<K, keyof I>`), so that `I[K & keyof I]`
type-checks. The resulting checks are unchanged: for keys missing from
`I` the old `I[K]` resolved to `unknown`, i.e. no excess-property check
for that key, and `P &` still contributes every property.

Adds type tests for unknown properties at the top level, in nested and
repeated messages and inside oneof cases, and re-generates the
integration output.
@infodusha
infodusha force-pushed the fix/exact-typescript-7-1 branch from d4631b4 to 2b221c8 Compare October 20, 2026 20:55
@infodusha
infodusha marked this pull request as draft October 20, 2026 20:57
@infodusha
infodusha marked this pull request as ready for review October 20, 2026 21:00
@stephenh

Copy link
Copy Markdown
Owner

This is great @infodusha , thank you!

@stephenh
stephenh merged commit 1c5aca0 into stephenh:main Oct 22, 2026
7 checks passed
stephenh pushed a commit that referenced this pull request Oct 22, 2026
stephenh pushed a commit that referenced this pull request Nov 4, 2026
Fixes #1298

With `useNullAsOptional=true`, generated code stopped compiling in
2.18.1 when a nested message has an optional field. The nested
`fromPartial` call fails with TS2345: `Type 'null' is not assignable to
type 'string | undefined'`.

`Exact<null, ...>` evaluates to `never`, because it reaches `null & {}`.
So `Exact` drops `null` from optional fields. This is older than #1293:
with the previous alias the generated code compiled, but
`Data.fromPartial({ view: null })` was already rejected. After #1293 the
generated nested call fails too. I checked both aliases on TypeScript
5.9.3, but I did not work out why the previous alias let the nested call
through.

This adds `null` to the generated `Builtin` type when
`useNullAsOptional` is set, which is the workaround the issue suggests.
`Exact` now stops at `null`. Besides fixing the compile error, this
means `Data.fromPartial({ view: null })` now compiles, which matches the
field type `string | null | undefined`. The generated files in
`integration/use-null-as-optional` and
`integration/no-defaults-for-optionals-with-nulls` change by that one
line.

What I ran:

- New `tests/use-null-as-optional-test.ts`. It generates code for the
schema from the issue and type checks it with the TypeScript API. Before
the change, the compile test fails with the error above and the test for
`{ view: null }` fails. After it, both pass. A third test checks that an
unknown property is still rejected, and passes both ways.
- `tests/` and the two integration folders above: 70 tests pass.
- `tsc --noEmit` on `tsconfig.json` and `tests/tsconfig.json`, and on
the three changed generated files, on TypeScript 5.9.3.
- Prettier on `src/main.ts` and the new test.
- Regenerating `proto-2.ts` and `proto-3.ts` from their committed `.bin`
files gives output identical to this branch.

What I did not run:

- `yarn proto2ts` and `pbjs.sh`, because they need Docker and protoc. I
edited the `Builtin` line in the three generated files by hand.
`use-null-as-optional` has no `.bin`, so that file was not regenerated.
- The rest of the integration tests and the full `yarn tsc:check`.
- TypeScript 7.1, the version #1293 targeted.
stephenh pushed a commit that referenced this pull request Nov 4, 2026
## [2.18.4](v2.18.3...v2.18.4) (2026-11-04)

### Bug Fixes

* treat null as a builtin with useNullAsOptional ([#1300](#1300)) ([8f56223](8f56223)), closes [#1298](#1298) [#1293](#1293) [#1293](#1293) [#1293](#1293)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants