Skip to content

Experiment - Enable TypeScript strict mode#3640

Draft
tegan-temporal wants to merge 27 commits into
mainfrom
ts-strict-mode
Draft

Experiment - Enable TypeScript strict mode#3640
tegan-temporal wants to merge 27 commits into
mainfrom
ts-strict-mode

Conversation

@tegan-temporal

@tegan-temporal tegan-temporal commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Description & motivation 💭

Enables TypeScript strict: true in the base tsconfig.json, so pnpm check (already run in CI via lint-and-test.yml) now enforces the full strict flag set. The repo previously built with strict: false.

Rolled out as an incremental ratchet — one flag group at a time, each driven to zero before enabling the next (~930 errors total):

Scope Approach Errors
residual flags useUnknownInCatchVariables, strictFunctionTypes, … 34
noImplicitAny explicit types for params / index access / fixtures 236
Storybook play typing correct play-fn context types 5
strictNullChecks guards / ?. / nullish defaults at use sites ~690
flip strict: true in base tsconfig

Fixes were made at use sites (guards, optional chaining, nullish defaults) rather than ! assertions — only a handful of ! in tests / provably-safe spots. Documented as unknown as only at genuine boundaries (REST base64 string vs proto Uint8Array; zod input/output). No exported signatures widened except a few deliberate, verified cases. Later commits fold in code-review feedback (tighter types instead of casts, prop types that were non-null but defaulted undefined, dead-code and comment cleanup).

tsconfig.strict.json + scripts/count-strict-errors.ts (the Danger tracker) are retained and now report 0 — a regression guard.

Testing 🧪

How was this tested 👻

  • pnpm check (tsgo, the CI gate) → 0 errors

  • svelte-check against tsconfig.strict.json (standard compiler, what the Danger strict tracker uses) → 0 errors — the two compilers diverge slightly, so both are verified

  • Unit (vitest) → 2226 passing, 0 failing (behavior verified, not just types — e.g. a real truthiness regression was caught by a snapshot and fixed in code)

  • Integration (Playwright) → 258 passing, 1 skipped

  • E2E (Playwright) → 28 passing, 5 skipped, 1 flaky (schedules.spec empty-list → create-form nav; passed on retry)

  • Manual testing — not done

  • E2E / integration suites run (green)

  • Unit tests added / updated

Reviewer notes / risk 🔎

Most of the diff is type-only, but strictNullChecks touched runtime in places. Hotspots worth a closer look:

  • Model decode normalizations (workflow-execution.ts, simplify-attributes.ts) — nullundefined/''. WorkflowExecution.pendingWorkflowTask / defaultWorkflowTaskTimeout were made optional (genuinely absent at runtime) instead of defaulting to {}, which would have flipped {#if} truthiness.
  • Schedule form bind:value getter/setter rewrites (spec-type-interval, …). (Note: the one flaky e2e is on the schedules page — passed on retry, but worth a glance.)
  • Added {#if} guards in templates/snippets (pending activity/task heartbeat, workflow-nexus-links).
  • persisted-search-parameter.ts — minor change to what's persisted (fixes a latent bug).

Out of scope / follow-up

This PR does not address the repo's pre-existing lint warnings (~168 svelte-check + ~118 eslint, largely state_referenced_locally, require-each-key, unused vars). They exist on main today and are best handled in a separate cleanup PR to keep this one focused on the strict flip.

Checklists

Merge Checklist

  • Refresh from main (up to date)
  • Run E2E / integration suites (green)
  • Review the behavior-change hotspots above
  • Decide squash vs. keeping the phased commits

Issue(s) closed

Set up incremental strict-mode adoption:
- tsconfig.strict.json is now a ratchet (strict:true with noImplicitAny
  and strictNullChecks still disabled) so remaining flags are gated.
- Add check:strict script.

Fix the 32 residual errors (useUnknownInCatchVariables + strictFunctionTypes):
- request-from-api: type handleError option as ErrorCallback.
- paginated callbacks: type token param as NextPageToken.
- narrow unknown catch vars before accessing .message / passing as Error.
- widen contravariant component handler params (Event, value unions).

Note: 2 pre-existing errors remain in event-link.ts (link.workflow does
not exist on ILink) — these also fail the base `pnpm check` and are out
of scope for strict mode.
Add explicit types across utilities, models, holocene, components,
services, stores, and vendor code so noImplicitAny passes:
- proper domain types for test fixtures and callback params
- keyof/index-signature typing for dynamic property access
- Svelte 5 typed props, event params, and snippet params
- ambient module declaration for the untyped oidc-provider dev dep

Enable noImplicitAny in the strict ratchet (tsconfig.strict.json).
Remaining: strictNullChecks (still disabled) and the 2 pre-existing
event-link.ts errors (tracked separately).
The `if (link.workflow)` block referenced a `workflow` field that does
not exist on the proto ILink type (which only has workflowEvent,
batchJob, activity, nexusOperation). It was hallucinated draft code
from #3620 ("WIP unreviewed changes driven by codex"); the real
workflow-link case is already handled by the link.workflowEvent branch
above. This block was unreachable and failed `pnpm check`.
The noImplicitAny pass annotated extracted play/focus consts with
StoryContext<ComponentProps<...>>, which is narrower than the context
the play prop provides and failed under strictFunctionTypes. Type the
reusable play consts structurally (canvasElement + step) so they are a
valid supertype of the play context; inline the date-picker focus
helper. Ratchet (strict:true, strictNullChecks:false) is now clean.
Handle null/undefined across the codebase so strictNullChecks passes,
fixing at use sites with guards, optional chaining, and nullish
defaults rather than non-null assertions (only a handful of `!` in
tests/provably-safe spots). Notable:
- models: proto (all-optional) reads normalized to domain types.
- services/utilities: documented REST base64 vs proto Uint8Array casts
  only at genuine boundaries; local Replace<> types for paginated token.
- WorkflowExecution.pendingWorkflowTask/defaultWorkflowTaskTimeout made
  optional (they are genuinely absent at runtime) instead of defaulting
  to {} — preserves falsy behavior for `{#if}` guards.
- getValueForFirstKey returns undefined (not '') to match behavior.
- remove obsolete event-link workflow-variant tests (dead branch).

Flip strictNullChecks on in the strict ratchet (tsconfig.strict.json is
now full `strict: true`). Full unit suite passes; base check stays clean.
Flip `strict: true` in tsconfig.json so `pnpm check` (already run in
CI via lint-and-test.yml) enforces the full strict flag set. All prior
phases cleared the errors, so this is a clean flip. Remove the interim
check:strict script; tsconfig.strict.json is retained for the Danger
count-strict-errors tracker (now reporting 0, serving as a guard).
@tegan-temporal tegan-temporal self-assigned this Jul 8, 2026
@vercel

vercel Bot commented Jul 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
holocene Ready Ready Preview, Comment Jul 9, 2026 8:59pm

Request Review

# Conflicts:
#	src/lib/components/search-attributes-form/search-attributes-form-content.svelte
@tegan-temporal tegan-temporal changed the title experiment - ts strict mode Enable TypeScript strict mode Jul 8, 2026
@tegan-temporal tegan-temporal changed the title Enable TypeScript strict mode Experiment - Enable TypeScript strict mode Jul 8, 2026
The Danger strict tracker uses standard svelte-check (svelte2tsx + tsc);
`pnpm check` uses --tsgo, which didn't flag these. Reconcile Svelte-4
$$Props (extends HTML*Attributes, so props are nullable) with the
narrower export-let declarations:
- button: target accepts `string | null | undefined`
- accordion: `export let icon` typed IconName (was implicitly `null`)
- checkbox: override id/disabled to non-null in $$Props
- radio-input: override disabled to non-null in RadioInputProps

Both standard svelte-check and tsgo now report 0.
range-input: make min/max/id required (component produces NaN without
min/max; all callers already pass them); type step as number | undefined.
checkbox: type group as T[] | undefined to match $$Props and the internal
group !== undefined guards. Removes the 'undefined as unknown as X' casts.
Remove comments that restate the adjacent cast (structurally-identical
zod/domain types, search-attribute shapes, Memo narrowing) and shorten the
two verbose schedule-spec blocks. Keep only comments stating external facts
a reader can't derive from the code: base64-vs-Uint8Array wire encoding,
opaque-JSON REST boundary, the null filter sentinel, the single-attribute-key
invariant, requestFromAPI's undefined contract, and the OIDC .d.ts shim.
# Conflicts:
#	src/lib/services/standalone-nexus-operations.ts
Now that strict mode is fully enabled and the codebase is strict-clean,
the Danger strict-mode check fails (was: warned) when a changed file
introduces a TypeScript error under the strict config. Reframe the
message from migration-tracking ("% of total") to enforcement. Infra
failures (script/parse errors) stay as warnings so a tooling hiccup
doesn't block all PRs.

The check-types CI job (`pnpm check`, strict tsconfig) continues to
hard-block on tsgo-detected errors; Danger (standard compiler) covers
the errors tsgo under-reports.
svelte-check's machine-verbose output uses 0-indexed LSP positions.
count-strict-errors passed them straight through, so Danger's inline
annotations landed one line above the actual error, and the
lines-in-diff gate (built from git's 1-indexed hunk headers) compared
against 0-indexed lines. Convert start line/character to 1-indexed at
the source. Verified with a probe: an error on line 3 now reports 3.
Drop 10 unused imports flagged by eslint no-unused-vars across the
touched areas (page, Badge, pauseLiveUpdates, Payload, base, afterEach,
PotentiallyDecodable, UserMetadata, isRawPayloads, SearchAttributeTypeOption).
Also remove two unused `const namespace` literals in event-history test
files edited by this branch. Left pre-existing unused locals in files
this change didn't touch (and the load `parent()` call, which has a
side effect) alone. Type-checks (tsgo + standard) and full test suite
stay green.

<!-- Info Alert -->
<Alert intent="info" class="text-sm">
<Icon name="info" slot="icon" />

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alert does not have an icon slot... this wasn't being rendered to begin with. It just became noticeable due to the strictness change

An earlier commit removed the `if (link.workflow)` handler believing it
was dead/hallucinated code. That was wrong: `temporal.api.common.v1.ILink`
has a real `workflow` oneof variant (Link.IWorkflow: namespace,
workflowId, runId, reason) — see @temporalio/proto@1.19.0 root.d.ts.
The removal was made while local node_modules were stale at proto 1.17.2
(where the field didn't yet exist), so it type-checked but silently
regressed: events carrying link.workflow fell through to unknownLink()
and rendered as non-clickable "Link" text instead of a Workflow ID link.

Restore the branch and its two covering tests. Verified: strict check
(tsgo + standard) clean, event-link tests pass against proto 1.19.0.
- activity-options-update-drawer: default backoffCoefficient to 2 (server
  default) instead of 0 — Temporal rejects < 1, and the full field mask
  always submits it. (finding 2)
- getFirstWholeNumberUnit: add overloads so passing a defaultUnit returns
  a non-undefined label; narrow initialTimeoutUnit to DefaultUnits and drop
  4 now-dead `?? 'second(s)'` fallbacks. (finding 4)
- dangerfile: match changed files by normalized path equality instead of
  fragile bidirectional substring (missed regressions / false-blocked
  suffix collisions). (finding 5)
- timeline-graph-row: guard divide-by-zero so an undefined workflowDistance
  yields 0, not Infinity, as an SVG coordinate. (finding 7)
- rename MostRecentWOrkflowVersionStamp -> MostRecentWorkflowVersionStamp
  typo (now load-bearing imported surface). (finding 9)

Deferred: #3 (requestFromAPI -> Promise<T|undefined> is a 128-call-site
refactor, its own PR), #6 (documented zod input/output boundary), #8
(pre-existing on main, not this change).
On the notifyOnError path requestFromAPI resolves without a body, so the
declared Promise<T> was a lie (previously papered over with
`undefined as unknown as T`). Type it as Promise<T | undefined> and
return undefined honestly.

Handle the surfaced undefined at the service boundary (19 files in
src/lib/services/) so each exported service keeps its existing return
contract and the change doesn't cascade into component callers: list
responses default to `[]`, object responses to `{}` / typed empties,
etc. Happy-path behavior is unchanged (fallbacks only trigger on the
handled-error path, which previously crashed callers on property
access). Four internal helpers (query-service fetchQuery,
workflow-activities requestWithActivityFallback, the two poll* fns) were
widened to `| undefined` instead of defaulted — their callers already
guard undefined, and defaulting poll* would have broken the long-poll
empty-response skip.
getFormSpecInitialData parsed partial seeds and cast the result to
FormSpecSchema, claiming a fully-formed spec it did not produce. Split the
bare object out of the refined schema (formSpecObject) and parse seeds
through it so zod injects the field defaults, yielding a genuine
FormSpecSchema with no cast. This matches the shape submit-time validation
already produces, so final data is unchanged.
Every caller now passes a default, so the no-default behaviors (returning
undefined on no match, falling back to the last unit on zero) were dead.
Requiring defaultUnit collapses the three overload signatures into one that
returns a plain UnitLabelT, and the schedule policy drawer passes 'second(s)'
directly instead of coalescing the result (behavior-identical, since seconds
was already the last unit).
getFormSpecFromSpec built partial input specs and cast the array to
FormSpecSchema[]. Parse each spec through the bare object schema
(parseFormSpec) so zod injects the field defaults, yielding a genuine
FormSpecSchema with no cast.

Because parsing now fills the sibling field (a frozen interval spec gains a
default calendar), realign the frozen type label to distinguish calendar vs
interval by the interval value rather than calendar presence, matching how
summarize.ts already discriminates.
backoffCoefficient/maximumAttempts bind to number inputs, whose DOM value is
a string, so typing them as string throughout matches reality and the
RetryPolicyInput props. The request builder already coerces via Number(...)
guarded by truthiness, so the form no longer double-converts and the
StandaloneActivityFormData cast is gone. Adds coverage for the builder's
string->number coercion and empty-field omission.
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.

1 participant