TypeScript Conventions
Component Conventions
Script Setup
All components use <script setup lang="ts">.
Props and Emits
Always declare defineProps and defineEmits using named interfaces, not inline type literals. This is enforced by the vue/define-emits-declaration and vue/define-props-declaration ESLint rules.
<script setup lang="ts">
import type { Publication } from "src/graphql/generated/graphql"
interface Props {
publication: Publication
mutable?: boolean
}
interface Emits {
update: [publication: Publication]
delete: []
}
const props = withDefaults(defineProps<Props>(), {
mutable: false,
})
const emit = defineEmits<Emits>()
</script>Key points:
- Import generated GraphQL types for prop types (
Publication,User,Submission, etc.) rather than defining parallel interfaces. - Use
withDefaultsfor optional props with default values. - Emit payloads use tuple syntax:
eventName: [arg1: Type, arg2: Type].
Type-Safe Provide/Inject
Vue's provide/inject is untyped by default -- inject("key") returns unknown and there is no compile-time check that the provider and consumer agree on the value type. We use InjectionKey<T> to make this type-safe.
The pattern is centralized in composable files like src/use/submissionContext.ts:
import type { InjectionKey, Ref, ComputedRef } from "vue"
import type { Submission } from "src/graphql/generated/graphql"
// Typed key -- binds the symbol to its value type
export const submissionKey: InjectionKey<ComputedRef<Submission | undefined>> =
Symbol("submission")
// Typed accessor -- consumers get the correct type automatically
export function useSubmission() {
return inject(submissionKey)!
}
// Centralized provider -- one function provides all related context
export function provideSubmissionReviewContext(options: {
submission: ComputedRef<Submission | undefined>
activeComment?: Ref<ActiveComment | null>
}) {
provide(submissionKey, options.submission)
provide(activeCommentKey, options.activeComment ?? ref(null))
}This pattern prevents three classes of bugs:
- Type mismatches -- providing a
Ref<string>where aComputedRef<Submission>is expected is a compile error. - Forgotten provides -- grouping all related provides in one function makes it harder to forget one.
- Wrong key -- using a typed
InjectionKeyinstead of a plain string means typos are caught at compile time.
Utility Type Patterns
Deriving Form Types from GraphQL Types
The FormStrings<T> mapped type transforms a generated GraphQL type into a form-friendly shape by stripping __typename, removing optionality, and unwrapping nullables:
type FormStrings<T> = {
[K in keyof T as K extends "__typename" ? never : K]-?: NonNullable<T[K]>
}
// Usage
type SocialMediaFields = FormStrings<SocialMedia>
// Result: { twitter: string; instagram: string; ... }Structural Interfaces for Composables
Composables that accept Apollo query/mutation results use structural interfaces rather than importing Apollo's exact types. This makes them easier to test with plain objects:
interface MutationLike {
loading: Ref<boolean>
error?: Ref<Error | null>
}
export function useFormState(query: QueryLike | null, mutation: MutationLike) {
// ...
}Vuelidate Validator Type
Vuelidate's built-in BaseValidation type is too complex for Vue's prop system. A custom structural interface captures only the fields our components actually use:
// src/types/vuelidate.ts
export interface VuelidateValidator {
$model: unknown
$path: string
$error: boolean
$errors: ErrorObject[]
$dirty: boolean
$touch: () => void
$reset: () => void
}Known Limitations
A few areas still use as any or ESLint suppressions. These are tracked with context and suggested fixes in client/REFACTOR.md:
wrapper.vm as any-- Vue test-utils does not expose<script setup>internals through TypeScript.- Vuelidate dynamic state -- 6 locations access Vuelidate properties (e.g.,
$response.complexity) beyond what ourVuelidateValidatormodels. - Dialog runtime emits -- Quasar's
useDialogPluginComponentrequires the runtime array form ofdefineEmits, preventing type-based declarations.