Coding Standards
These standards apply to all frontend code in the CPR (Computerized Patient Record) project. The stack is Nuxt 4 + Vue 3 + TypeScript + Pinia + Tailwind CSS running in SPA mode with the app/ directory structure.
TypeScript
Strict Mode
TypeScript strict mode is enabled. Every variable, parameter, return value, and reactive reference must have an explicit or inferable type. Do not rely on implicit any.
No any -- Use unknown and Narrow
Never use any. When the type is truly unknown, use unknown and narrow before accessing properties.
// bad
catch (err: any) {
console.error(err.message);
}
// good
catch (err: unknown) {
const message =
err instanceof Error ? err.message : 'Something went wrong';
error.value = message;
}Prefer const Over let
Use const for all bindings that are not reassigned. Only use let when reassignment is required (e.g., timeout IDs). Never use var.
// bad
let doctorService = new DoctorService();
// good
const doctorService = new DoctorService();Type API Errors as FetchError
When catching errors from service or API calls, type them using FetchError from ofetch so you can safely access the response payload.
import type { FetchError } from 'ofetch';
catch (err: unknown) {
const fetchError = err as FetchError<{ message?: string }>;
const message =
fetchError.data?.message ??
fetchError.message ??
'Something went wrong';
error.value = message;
}Vue Components
Script Setup Required
Every component must use <script setup lang="ts">. Do not use the Options API or the non-setup Composition API.
<script setup lang="ts">
import { computed } from 'vue';
import type { Doctor } from '~/types/doctor.type';
// component logic here
</script>Props with Interface
Define props using defineProps with a TypeScript interface. Use withDefaults when default values are needed. Name the local interface Props.
<script setup lang="ts">
import type { Component } from 'vue';
interface Props {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
loading?: boolean;
leftIcon?: Component;
}
const props = withDefaults(defineProps<Props>(), {
variant: 'primary',
size: 'md',
disabled: false,
loading: false,
leftIcon: undefined,
});Typed Emits
Define emits with full type signatures using defineEmits.
<script setup lang="ts">
import type { Doctor } from '~/types/doctor.type';
// Object syntax (preferred for multiple events)
defineEmits<{
(e: 'edit' | 'delete', doctor: Doctor): void;
(e: 'page-change', page: number): void;
}>();
// Shorthand syntax (fine for simple events)
const emit = defineEmits<{ close: [] }>();Template Refs
Type template refs explicitly. Do not use untyped ref(null).
<script setup lang="ts">
const formRef = ref<HTMLFormElement | null>(null);
const inputRef = ref<InstanceType<typeof UiInput> | null>(null);
</script>Reactivity
Use ref, reactive, and computed Appropriately
| Tool | When to use |
|---|---|
ref | Primitive values, individual pieces of state (loading, error, success) |
reactive | Object state that is always accessed together (form data, complex state) |
computed | Derived values that depend on other reactive state |
// ref for primitives
const loading = ref(false);
const error = ref('');
// reactive for form objects
const form = reactive<Partial<Doctor>>({
first_name: '',
middle_name: null,
last_name: '',
doctor_role_id: undefined,
status: 'active',
});
// computed for derived state
const doctorRoles = computed(() => enums.doctor_roles || []);API Calls
Always Use useApiFetch or Service Classes
Never call $fetch or fetch directly. All API calls must go through useApiFetch (which injects the auth token and base URL) or through a service class that wraps useApiFetch.
// bad - raw $fetch bypasses auth token injection
const data = await $fetch('/api/doctors');
// good - service class (preferred for domain logic)
const doctors = await doctorService.getDoctors(filters);
// good - useApiFetch directly (for one-off calls)
const res = await useApiFetch<ApiResponse<Doctor>>('/doctors', {
method: 'GET',
params,
});Service Layer with Sentry
Service classes must catch errors, report to Sentry, and re-throw with a clean message. Follow the pattern established by BasicCrudService.
import type { FetchError } from 'ofetch';
import * as Sentry from '@sentry/nuxt';
class DoctorService {
private getErrorMessage(err: unknown): string {
const fetchError = err as FetchError<unknown>;
return (
(fetchError.data as { message?: string })?.message ??
fetchError.message ??
'Something went wrong'
);
}
async getDoctor(id: number): Promise<Doctor> {
try {
const res: ApiResponse<Doctor> = await useApiFetch(`/doctors/${id}`, {
method: 'GET',
});
return res.data;
} catch (err: unknown) {
const message = this.getErrorMessage(err);
Sentry.captureException(err);
throw new Error(message);
}
}
}
export const doctorService = new DoctorService();
export default doctorService;Extending BasicCrudService
For domains with standard CRUD, extend BasicCrudService instead of writing boilerplate. Add custom methods only for domain-specific endpoints.
import BasicCrudService from '~/sevices/basicCrud.service';
class SupplierService extends BasicCrudService {
constructor() {
super('suppliers');
}
// Only add methods that BasicCrudService does not cover
async getActiveSuppliers(): Promise<Supplier[]> {
// ...
}
}
export const supplierService = new SupplierService();
export default supplierService;Error Handling
Composable Error Pattern
Every composable that performs async operations must expose loading, error, and success refs. Always reset them before the operation and set them in finally.
export const useCreateDoctor = () => {
const success = ref(false);
const loading = ref(false);
const error = ref('');
const createDoctor = async (params: Partial<Doctor>) => {
loading.value = true;
success.value = false;
error.value = '';
try {
await doctorService.createDoctor(params);
success.value = true;
} catch (err) {
const message =
err instanceof Error ? err.message : 'Something went wrong';
error.value = message;
success.value = false;
} finally {
loading.value = false;
}
};
return { loading, error, success, createDoctor };
};Composition API Only
Do not use Options API (data(), methods, computed properties as objects, watch as objects, lifecycle hooks as object keys). Use the Composition API equivalents exclusively.
// bad
export default {
data() { return { loading: false } },
methods: { async fetchDoctors() { ... } },
}
// good
const loading = ref(false);
const fetchDoctors = async () => { ... };