Skip to content

Last updated:

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.

ts
// 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.

ts
// 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.

ts
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.

vue
<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.

vue
<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.

vue
<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).

vue
<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

ToolWhen to use
refPrimitive values, individual pieces of state (loading, error, success)
reactiveObject state that is always accessed together (form data, complex state)
computedDerived values that depend on other reactive state
ts
// 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.

ts
// 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.

ts
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.

ts
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.

ts
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.

ts
// bad
export default {
  data() { return { loading: false } },
  methods: { async fetchDoctors() { ... } },
}

// good
const loading = ref(false);
const fetchDoctors = async () => { ... };

CPR - Clinical Patient Records