fix: apply exactOptionalPropertyTypes shims to adapters and services

- Add optionalProps shim to base.adapter.ts for RequestInit compatibility
- Apply optionalProps to UserSettingsModal updateData
- Use conditional spreading for Navigation Button element prop
- Apply optionalProps to drag-drop target object creation
- Apply optionalProps to party.service create/update methods
- Apply optionalProps to grid.service CRUD operations
- Fix transcendenceStage -> transcendenceStep typo in grid.service
- Update UserUpdateParams interface to include | undefined
- Update DragOperation interface properties to include | undefined

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-11-28 18:42:38 -08:00
parent 16e24e337b
commit a74653ee93
9 changed files with 154 additions and 35 deletions

View file

@ -19,6 +19,7 @@ import {
} from './errors' } from './errors'
import { authStore } from '$lib/stores/auth.store' import { authStore } from '$lib/stores/auth.store'
import { browser } from '$app/environment' import { browser } from '$app/environment'
import { optionalProps } from '$lib/utils/typeShims'
/** /**
* Base adapter class that all resource-specific adapters extend from. * Base adapter class that all resource-specific adapters extend from.
@ -124,9 +125,9 @@ export abstract class BaseAdapter {
} }
} }
// Prepare request options // Prepare request options (filter out undefined to satisfy exactOptionalPropertyTypes)
const fetchOptions: RequestInit = { const fetchOptions: RequestInit = {
...options, // Allow overriding defaults ...optionalProps(options), // Allow overriding defaults, filter undefined
credentials: 'include', // Still include cookies for CORS and refresh token credentials: 'include', // Still include cookies for CORS and refresh token
signal: controller.signal, signal: controller.signal,
headers: { headers: {

View file

@ -113,7 +113,7 @@ export class SearchResource {
this.activeRequests.set(type, controller) this.activeRequests.set(type, controller)
// Update loading state // Update loading state
this[type] = { ...this[type], loading: true, error: undefined } this[type] = { ...this[type], loading: true }
try { try {
// Merge base params with provided params // Merge base params with provided params

View file

@ -1,11 +1,11 @@
import { userAdapter } from '../adapters/user.adapter' import { userAdapter } from '../adapters/user.adapter'
export interface UserUpdateParams { export interface UserUpdateParams {
picture?: string picture?: string | undefined
element?: string element?: string | undefined
gender?: number gender?: number | undefined
language?: string language?: string | undefined
theme?: string theme?: string | undefined
} }
export interface UserResponse { export interface UserResponse {

View file

@ -216,7 +216,7 @@
iconOnly iconOnly
shape="circle" shape="circle"
variant="primary" variant="primary"
element={userElement} {...(userElement ? { element: userElement } : {})}
elementStyle={Boolean(userElement)} elementStyle={Boolean(userElement)}
class="new-team-button" class="new-team-button"
aria-label="New team" aria-label="New team"

View file

@ -10,6 +10,7 @@
import type { UserCookie } from '$lib/types/UserCookie' import type { UserCookie } from '$lib/types/UserCookie'
import { setUserCookie } from '$lib/auth/cookies' import { setUserCookie } from '$lib/auth/cookies'
import { invalidateAll } from '$app/navigation' import { invalidateAll } from '$app/navigation'
import { optionalProps } from '$lib/utils/typeShims'
interface Props { interface Props {
open: boolean open: boolean
@ -72,14 +73,14 @@
saving = true saving = true
try { try {
// Prepare the update data // Prepare the update data (filter undefined to satisfy exactOptionalPropertyTypes)
const updateData = { const updateData = optionalProps({
picture, picture,
element: currentPicture?.element, element: currentPicture?.element,
gender, gender,
language, language,
theme theme
} })
// Call API to update user settings // Call API to update user settings
const response = await users.update(userId, updateData) const response = await users.update(userId, updateData)
@ -131,7 +132,12 @@
</script> </script>
<Dialog bind:open {onOpenChange} title="@{username}" description="Account Settings"> <Dialog
bind:open
{...(onOpenChange ? { onOpenChange } : {})}
title="@{username}"
description="Account Settings"
>
{#snippet children()} {#snippet children()}
<form onsubmit={handleSave} class="settings-form"> <form onsubmit={handleSave} class="settings-form">
{#if error} {#if error}

View file

@ -1,4 +1,5 @@
import type { GridCharacter, GridWeapon, GridSummon } from '$lib/types/api/party' import type { GridCharacter, GridWeapon, GridSummon } from '$lib/types/api/party'
import { optionalProps } from '$lib/utils/typeShims'
export type GridItemType = 'character' | 'weapon' | 'summon' export type GridItemType = 'character' | 'weapon' | 'summon'
export type GridItem = GridCharacter | GridWeapon | GridSummon export type GridItem = GridCharacter | GridWeapon | GridSummon
@ -30,13 +31,13 @@ export interface DragOperation {
container: string container: string
position: number position: number
itemId: string itemId: string
type?: GridItemType type?: GridItemType | undefined
} }
target: { target: {
container: string container: string
position: number position: number
itemId?: string itemId?: string | undefined
type?: GridItemType type?: GridItemType | undefined
} }
status: 'pending' | 'synced' | 'failed' status: 'pending' | 'synced' | 'failed'
retryCount: number retryCount: number
@ -224,12 +225,12 @@ export function createDragDropContext(handlers: DragDropHandlers = {}) {
itemId: state.draggedItem.data.id, itemId: state.draggedItem.data.id,
type: state.draggedItem.source.type type: state.draggedItem.source.type
}, },
target: { target: optionalProps({
container: state.hoveredOver.container, container: state.hoveredOver.container,
position: state.hoveredOver.position, position: state.hoveredOver.position,
itemId: targetItem?.id || undefined, itemId: targetItem?.id,
type: state.hoveredOver.type type: state.hoveredOver.type
}, }),
status: 'pending', status: 'pending',
retryCount: 0 retryCount: 0
} }

View file

@ -1,6 +1,7 @@
import type { Party, GridWeapon, GridSummon, GridCharacter } from '$lib/types/api/party' import type { Party, GridWeapon, GridSummon, GridCharacter } from '$lib/types/api/party'
import { gridAdapter } from '$lib/api/adapters/grid.adapter' import { gridAdapter } from '$lib/api/adapters/grid.adapter'
import { partyAdapter } from '$lib/api/adapters/party.adapter' import { partyAdapter } from '$lib/api/adapters/party.adapter'
import { optionalProps } from '$lib/utils/typeShims'
export interface GridOperation { export interface GridOperation {
type: 'add' | 'replace' | 'remove' | 'move' | 'swap' type: 'add' | 'replace' | 'remove' | 'move' | 'swap'
@ -38,13 +39,13 @@ export class GridService {
): Promise<GridUpdateResult> { ): Promise<GridUpdateResult> {
try { try {
// Note: The backend computes the correct uncap level based on the weapon's FLB/ULB/transcendence flags // Note: The backend computes the correct uncap level based on the weapon's FLB/ULB/transcendence flags
const gridWeapon = await gridAdapter.createWeapon({ const gridWeapon = await gridAdapter.createWeapon(optionalProps({
partyId, partyId,
weaponId, weaponId,
position, position,
mainhand: options?.mainhand, mainhand: options?.mainhand,
transcendenceStep: 0 transcendenceStep: 0
}, this.buildHeaders(editKey)) }), this.buildHeaders(editKey))
console.log('[GridService] Created grid weapon:', gridWeapon) console.log('[GridService] Created grid weapon:', gridWeapon)
@ -122,12 +123,12 @@ export class GridService {
editKey?: string, editKey?: string,
options?: { shortcode?: string } options?: { shortcode?: string }
): Promise<Party | null> { ): Promise<Party | null> {
await gridAdapter.updateWeapon(gridWeaponId, { await gridAdapter.updateWeapon(gridWeaponId, optionalProps({
position: updates.position, position: updates.position,
uncapLevel: updates.uncapLevel, uncapLevel: updates.uncapLevel,
transcendenceStep: updates.transcendenceStep, transcendenceStep: updates.transcendenceStep,
element: updates.element element: updates.element
}, this.buildHeaders(editKey)) }), this.buildHeaders(editKey))
// Clear party cache if shortcode provided // Clear party cache if shortcode provided
if (options?.shortcode) { if (options?.shortcode) {
@ -207,14 +208,14 @@ export class GridService {
options?: { main?: boolean; friend?: boolean; shortcode?: string } options?: { main?: boolean; friend?: boolean; shortcode?: string }
): Promise<Party> { ): Promise<Party> {
// Note: The backend computes the correct uncap level based on the summon's FLB/ULB/transcendence flags // Note: The backend computes the correct uncap level based on the summon's FLB/ULB/transcendence flags
const gridSummon = await gridAdapter.createSummon({ const gridSummon = await gridAdapter.createSummon(optionalProps({
partyId, partyId,
summonId, summonId,
position, position,
main: options?.main, main: options?.main,
friend: options?.friend, friend: options?.friend,
transcendenceStage: 0 transcendenceStep: 0
}, this.buildHeaders(editKey)) }), this.buildHeaders(editKey))
console.log('[GridService] Created grid summon:', gridSummon) console.log('[GridService] Created grid summon:', gridSummon)
@ -270,12 +271,12 @@ export class GridService {
editKey?: string, editKey?: string,
options?: { shortcode?: string } options?: { shortcode?: string }
): Promise<Party | null> { ): Promise<Party | null> {
await gridAdapter.updateSummon(gridSummonId, { await gridAdapter.updateSummon(gridSummonId, optionalProps({
position: updates.position, position: updates.position,
quickSummon: updates.quickSummon, quickSummon: updates.quickSummon,
uncapLevel: updates.uncapLevel, uncapLevel: updates.uncapLevel,
transcendenceStage: updates.transcendenceStep transcendenceStep: updates.transcendenceStep
}, this.buildHeaders(editKey)) }), this.buildHeaders(editKey))
// Clear party cache if shortcode provided // Clear party cache if shortcode provided
if (options?.shortcode) { if (options?.shortcode) {
@ -436,12 +437,12 @@ export class GridService {
editKey?: string, editKey?: string,
options?: { shortcode?: string } options?: { shortcode?: string }
): Promise<GridCharacter | null> { ): Promise<GridCharacter | null> {
const updated = await gridAdapter.updateCharacter(gridCharacterId, { const updated = await gridAdapter.updateCharacter(gridCharacterId, optionalProps({
position: updates.position, position: updates.position,
uncapLevel: updates.uncapLevel, uncapLevel: updates.uncapLevel,
transcendenceStep: updates.transcendenceStep, transcendenceStep: updates.transcendenceStep,
perpetuity: updates.perpetuity perpetuity: updates.perpetuity
}, this.buildHeaders(editKey)) }), this.buildHeaders(editKey))
// Clear party cache if shortcode provided // Clear party cache if shortcode provided
if (options?.shortcode) { if (options?.shortcode) {

View file

@ -2,6 +2,7 @@ import type { Party } from '$lib/types/api/party'
import { partyAdapter } from '$lib/api/adapters/party.adapter' import { partyAdapter } from '$lib/api/adapters/party.adapter'
import { authStore } from '$lib/stores/auth.store' import { authStore } from '$lib/stores/auth.store'
import { browser } from '$app/environment' import { browser } from '$app/environment'
import { optionalProps } from '$lib/utils/typeShims'
/** /**
* Context type for party-related operations in components * Context type for party-related operations in components
@ -66,18 +67,18 @@ export class PartyService {
party: Party party: Party
editKey?: string editKey?: string
}> { }> {
const apiPayload = this.mapToApiPayload(payload) const apiPayload = optionalProps(this.mapToApiPayload(payload))
const party = await partyAdapter.create(apiPayload) const party = await partyAdapter.create(apiPayload)
// Note: Edit key handling may need to be adjusted based on how the API returns it // Note: Edit key handling may need to be adjusted based on how the API returns it
return { party, editKey: undefined } return { party }
} }
/** /**
* Update party details * Update party details
*/ */
async update(id: string, payload: PartyUpdatePayload, editKey?: string): Promise<Party> { async update(id: string, payload: PartyUpdatePayload, editKey?: string): Promise<Party> {
const apiPayload = this.mapToApiPayload(payload) const apiPayload = optionalProps(this.mapToApiPayload(payload))
return partyAdapter.update({ shortcode: id, ...apiPayload }) return partyAdapter.update({ shortcode: id, ...apiPayload })
} }
@ -110,7 +111,7 @@ export class PartyService {
const party = await partyAdapter.remix(shortcode) const party = await partyAdapter.remix(shortcode)
// Note: Edit key handling may need to be adjusted // Note: Edit key handling may need to be adjusted
return { party, editKey: undefined } return { party }
} }
/** /**

109
src/lib/utils/typeShims.ts Normal file
View file

@ -0,0 +1,109 @@
/**
* Type utilities for handling exactOptionalPropertyTypes: true
*
* These utilities help work with third-party libraries and components
* that don't properly support exactOptionalPropertyTypes.
*/
/**
* Recursively converts optional properties from { key?: T } to { key?: T | undefined }
* This is needed for libraries like bits-ui that don't include undefined in optional props.
*
* @example
* ```ts
* type Original = { optional?: string }
* type Fixed = DeepAddUndefined<Original> // { optional?: string | undefined }
* ```
*/
export type DeepAddUndefined<Type> =
Type extends (infer Element)[]
? DeepAddUndefined<Element>[]
: Type extends (...args: unknown[]) => unknown
? Type
: Type extends object
? {
[Key in keyof Type]: undefined extends Type[Key]
? never
: DeepAddUndefined<Type[Key]>
} & {
[Key in keyof Type]?: undefined extends Type[Key]
? undefined | DeepAddUndefined<Type[Key]>
: never
}
: Type
/**
* Shim function that "fixes" types for exactOptionalPropertyTypes compatibility.
* Use this when passing props to third-party components that don't support exactOptionalPropertyTypes.
*
* @example
* ```ts
* <Dialog {...exactOptionalShim({ onOpenChange: myHandler })} />
* ```
*/
export function exactOptionalShim<Type>(value: Type): DeepAddUndefined<Type> {
return value as never
}
/**
* Simple single-level optional property fixer.
* Faster alternative to DeepAddUndefined when you don't need recursion.
*
* @example
* ```ts
* type Original = { optional?: string; required: number }
* type Fixed = OptionalUndefined<Original> // { optional?: string | undefined; required: number }
* ```
*/
export type OptionalUndefined<Type> = {
[Key in keyof Type as undefined extends Type[Key] ? never : Key]: Type[Key]
} & {
[Key in keyof Type as undefined extends Type[Key] ? Key : never]?: Type[Key] | undefined
}
/**
* Helper to conditionally include optional properties only when they're defined.
* This avoids assigning undefined to optional properties.
*
* @example
* ```ts
* const props = {
* required: 'value',
* ...optionalProp('optional', maybeUndefined),
* ...optionalProp('other', maybeOther)
* }
* ```
*/
export function optionalProp<K extends string, V>(
key: K,
value: V | undefined
): V extends undefined ? {} : { [P in K]: V } {
if (value === undefined) {
return {} as any
}
return { [key]: value } as any
}
/**
* Helper to build objects with optional properties that are only included when defined.
*
* @example
* ```ts
* const props = optionalProps({
* required: 'always included',
* optional: maybeUndefined, // only included if defined
* other: maybeOther // only included if defined
* })
* ```
*/
export function optionalProps<T extends Record<string, unknown>>(
obj: T
): Partial<T> {
const result: Partial<T> = {}
for (const [key, value] of Object.entries(obj)) {
if (value !== undefined) {
result[key as keyof T] = value
}
}
return result
}