<!-- adk-managed-skill -->
Converting LWC to TypeScript
Convert a Lightning Web Component bundle from JavaScript to TypeScript. The deliverable is a fully-typed .ts implementation plus a .d.ts file that only exposes @api members (the public surface other LWCs consume).
When to Use This Skill
- User wants to migrate a single component or a folder of components from
.js to .ts.
- User needs a
.d.ts for an existing LWC so other components (or an
external TypeScript host) can import it safely.
- User is adding type annotations to an already-renamed
.ts LWC that
hasn't been properly typed yet.
- User wants JSDoc-style type hints upgraded to real TypeScript types.
Prerequisites
- The component builds and runs correctly in JavaScript today.
git is available (the rename must preserve history via git mv).
- A TypeScript compiler is wired into the build (either the SFDX TS
pipeline or a standalone tsc step).
Workflow
Step 1 — Read the component
Open every file in the bundle:
componentName/
├── componentName.js
├── componentName.html
├── componentName.css
└── (possibly) __tests__/, __utam__/, existing .d.ts
Understand:
- What extends
LightningElement? What is the class name?
- Which fields and methods carry the
@api decorator?
- Which properties/methods have existing JSDoc (use as a type hint
starting point, but validate against actual usage — JSDoc lies).
- Which parameters / return types can you infer from how the code is
called internally?
Step 2 — Rename .js → .ts using git mv
git mv componentName/componentName.js componentName/componentName.ts
Repeat for any helper .js files in the bundle (unless they're already .ts). Never plain mv — that loses the history link TypeScript reviewers rely on.
Step 3 — Add type annotations in the .ts
Apply types in this priority order so you stop as soon as the public contract is solid:
@api properties and methods first. Generate JSDoc if it's
missing, then translate JSDoc types to TS syntax (string, number, boolean, Promise<T>). Validate each JSDoc claim against the code before trusting it.
- Complex shapes become
interface or type aliases — not inline
shapes repeated everywhere.
- Optional members use
? only when the value is genuinely allowed
to be undefined. Do not sprinkle ? defensively.
- Private/internal state — still type it, but don't export the
types. Use private for members that must never be touched by consumers.
- Event handlers — prefer precise DOM event types:
- MouseEvent for onclick (and other click-like handlers). click is dispatched as a MouseEvent — including keyboard-activated clicks — so typing it as PointerEvent would let handlers rely on pointer-only fields (pointerType, pressure, etc.) that are undefined in those cases. - PointerEvent for onpointerdown / onpointerup / onpointermove and other pointer* handlers where pointer-specific fields are actually meaningful. - CustomEvent<{ detail: ... }> for LWC custom events. - Event is the last resort; document why when using it.
- Async methods always return
Promise<T> — never bare T.
- Avoid
any. If you genuinely can't type something, use unknown
and narrow with a type guard.
Reference patterns
Load [[assets/type-patterns.ts|assets/type-patterns.ts]] as an inline example covering property types, method types, and event handler types.
Step 4 — Generate the .d.ts
Create componentName.d.ts next to the .ts. It must:
- Contain only
@api members — no private state, no internal
methods, no lifecycle hooks unless they are themselves @api.
- Preserve
@api JSDoc verbatim (including @type, @required,
@default, @param, @returns tags) directly above each declaration.
- Declare the LWC module namespace
c/componentName (or the org's
namespace if different).
Template: load [[assets/dts-template.ts|assets/dts-template.ts]] as the starting .d.ts shape.
If the component has no @api members, still produce the module declaration with a comment explaining there's no public surface — don't skip the file.
Step 5 — Compile and test
- Run the TypeScript compiler (
tsc --noEmit or the build's equivalent).
Resolve every error before calling it done; no @ts-ignore patches.
- Run the component's existing Jest tests. The behavior should be
identical.
- Run the bundled consumer-finder unconditionally — empty output is a
valid result, not a reason to skip. The script resolves the search paths from sfdx-project.json's packageDirectories (or falls back to <project-root>), rejects any entry that escapes the project root, and performs the LWC-import search internally so the invocation is fully deterministic:
"<skill_dir>/scripts/find-consumers.sh" "<project-root>" "<componentName>"
For each match, confirm the consumer's expected types still align with the new .d.ts public surface.
Step 6 — Expected final bundle shape
componentName/
├── componentName.ts # Main TypeScript implementation
├── componentName.html # Template (unchanged)
├── componentName.css # Styles (unchanged)
└── componentName.d.ts # Type definitions (new)
Verification Checklist
Before conversion:
After conversion:
(no implicit any).
inline repeats.
LightningElement, includes only @api members.
workaround.
Common Pitfalls
- Using
any to silence errors. Solve the actual type instead.
If the value is truly unknown, use unknown + a type guard.
- Including private members in the
.d.ts. The .d.ts is the
public contract. Internal lifecycle and helpers must not leak.
- Losing JSDoc during the rename. Scan before and after — JSDoc
comments on @api members must appear in both the .ts and .d.ts.
- Skipping
git mv. Makes review miserable and confuses blame.
- Forgetting async return types.
foo() with an async keyword
always returns a Promise. Declare it.
- Typing
onclick as PointerEvent. click is a MouseEvent
(keyboard-triggered clicks included), so PointerEvent fields like pointerType are undefined for those events. Type onclick as MouseEvent; reserve PointerEvent for onpointer* handlers. Use MouseEvent | TouchEvent only when the code branches on TouchEvent distinctly.
Support Resources