name: typescript description: Write TypeScript in this project correctly. Use when defining types for API responses, avoiding any, typing props/emits/refs, using generics with ubusCall, or fixing ESLint TypeScript warnings. Covers clean ESLint-compliant patterns without suppression comments. metadata: project: nethsecurity-ui eslint-config: eslint.config.js
TypeScript — Clean, ESLint-Compliant Code
The ESLint config (eslint.config.js) enforces @typescript-eslint/recommended and warns on no-explicit-any. Write code that satisfies the linter because it is well-typed, not because it suppresses warnings.
The any Problem
ubusCall has a legacy any-heavy signature for backward compatibility. That is known debt. Do not follow it in new code — always pass a generic type:
// ❌ Leaves result typed as any
const res = await ubusCall('ns.users', 'list-users')
// ✅ Explicit generic keeps the whole chain typed
type ListUsersResponse = { data: { values: User[] } }
const res = await ubusCall<ListUsersResponse>('ns.users', 'list-users')
const users = res.data.values // User[]
For catch blocks, use unknown and narrow:
} catch (err: unknown) {
if (err instanceof ValidationError) {
validationBag.value = err.errorBag
} else if (err instanceof Error) {
console.error(err.message)
}
}
Never add // eslint-disable-next-line @typescript-eslint/no-explicit-any — fix the type instead.
Define Response Types Explicitly
Always define the shape before using the response:
type User = {
id: string
username: string
role: 'admin' | 'user'
}
type ListUsersResponse = {
data: {
values: User[]
}
}
Use the generic wrapper pattern when many endpoints share the same envelope:
type ApiResponse<T> = { data: { values: T } }
const { data } = useQuery({
queryKey: ['users'],
queryFn: () => ubusCall<ApiResponse<User[]>>('ns.users', 'list'),
select: (res) => res.data.values,
})
Typing Refs and Reactive State
Always annotate when the initial value does not infer the full type:
const items = ref<Item[]>([]) // not ref([]) — that gives never[]
const selected = ref<Item>() // Item | undefined
const error = ref<string | undefined>()
Typing Props and Emits
Always use the type-based macro syntax:
// ✅ Correct
const { name, count = 0 } = defineProps<{
name: string
count?: number
}>()
const emit = defineEmits<{
close: []
save: [item: Item]
}>()
Never use the runtime form defineProps({ name: String }).
Union Types
Prefer union types over TypeScript enum — enums compile to runtime code and don't tree-shake:
type RuleAction = 'accept' | 'drop' | 'reject'
ESLint Rules in Effect
From eslint.config.js:
@typescript-eslint/no-explicit-any: warn — fix it, don't silence iteslint-plugin-vueflat/recommended — enforces Vue SFC ordering and patterns@vitest/eslint-plugin— catches test anti-patterns insrc/**/__tests__/*skipFormatting— Prettier handles formatting; ESLint does not check it
Gotchas
- Never add
eslint-disablesuppression comments forno-explicit-any. The legacyubusCallsignature hasanyparameters for backward compatibility — that is a known exception inubus.ts, not a pattern to follow. In all new code, pass a concrete generic type. ubusCallpayload parameter acceptsany— you don't need to type the payload argument explicitly when calling it; just type the return generic:ubusCall<ResponseType>('path', 'method', payload).useQueryerrorisError | null, notunknown. Don't add unnecessary type guards —getAxiosErrorMessage(error)from@nethesis/vue-componentshandlesnullalready.import typefor type-only imports — always useimport type { Foo }for interfaces/types to keep the compiler output clean. ESLint may enforce this ifconsistent-type-importsis added later.issue.path?.[0]?.keyin valibot can be anumberfor array field paths, not juststring. Cast carefully: only use it as aMessageBagkey when the form fields are plain object keys.