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
.jsto.ts. - User needs a
.d.tsfor an existing LWC so other components (or an external TypeScript host) can import it safely. - User is adding type annotations to an already-renamed
.tsLWC 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.
gitis available (the rename must preserve history viagit mv).- A TypeScript compiler is wired into the build (either the SFDX TS
pipeline or a standalone
tscstep).
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
@apidecorator? - 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:
@apiproperties 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
interfaceortypealiases — not inline shapes repeated everywhere. - Optional members use
?only when the value is genuinely allowed to beundefined. Do not sprinkle?defensively. - Private/internal state — still type it, but don't export the
types. Use
privatefor members that must never be touched by consumers. - Event handlers — prefer precise DOM event types:
MouseEventforonclick(and other click-like handlers).clickis dispatched as aMouseEvent— including keyboard-activated clicks — so typing it asPointerEventwould let handlers rely on pointer-only fields (pointerType,pressure, etc.) that are undefined in those cases.PointerEventforonpointerdown/onpointerup/onpointermoveand otherpointer*handlers where pointer-specific fields are actually meaningful.CustomEvent<{ detail: ... }>for LWC custom events.Eventis the last resort; document why when using it.
- Async methods always return
Promise<T>— never bareT. - Avoid
any. If you genuinely can't type something, useunknownand 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
@apimembers — no private state, no internal methods, no lifecycle hooks unless they are themselves@api. - Preserve
@apiJSDoc verbatim (including@type,@required,@default,@param,@returnstags) 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 --noEmitor the build's equivalent). Resolve every error before calling it done; no@ts-ignorepatches. - 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'spackageDirectories(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:
- Component is valid JS and all tests pass.
- You've identified every
@apimember and its intended type.
After conversion:
-
git mvwas used so history is preserved. - Every variable and parameter in the
.tshas a concrete type (no implicitany). - Complex object shapes live in
interface/typealiases, not inline repeats. - Optional
?is only on genuinely optional fields. -
.d.tsexists, declaresc/componentName, extendsLightningElement, includes only@apimembers. - Every
@apiJSDoc is preserved verbatim in the.d.ts. -
tscpasses with zero errors; no@ts-ignoreoranyused as a workaround. - Jest tests still pass.
Common Pitfalls
- Using
anyto silence errors. Solve the actual type instead. If the value is truly unknown, useunknown+ a type guard. - Including private members in the
.d.ts. The.d.tsis the public contract. Internal lifecycle and helpers must not leak. - Losing JSDoc during the rename. Scan before and after — JSDoc
comments on
@apimembers must appear in both the.tsand.d.ts. - Skipping
git mv. Makes review miserable and confuses blame. - Forgetting async return types.
foo()with anasynckeyword always returns aPromise. Declare it. - Typing
onclickasPointerEvent.clickis aMouseEvent(keyboard-triggered clicks included), soPointerEventfields likepointerTypeare undefined for those events. TypeonclickasMouseEvent; reservePointerEventforonpointer*handlers. UseMouseEvent | TouchEventonly when the code branches onTouchEventdistinctly.