Skip to content

TypeScript Best Practices

AkashJS is TypeScript-native. Every API is designed for inference first. These guidelines help you get the most out of the type system without fighting it.

Enable Strict Mode

Always use strict mode in tsconfig.json:

json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true
  }
}

What strict mode catches

  • null and undefined are not assignable to other types
  • Function parameters must be typed
  • this is typed in class methods
  • Unused locals and parameters are flagged

Leverage Inference -- Do Not Over-Annotate

AkashJS APIs infer types automatically. Adding explicit annotations where the compiler already knows the type adds noise.

ts
// DON'T: redundant type annotations
const count: Signal<number> = signal<number>(0);
const doubled: ReadonlySignal<number> = computed<number>(() => count() * 2);
const name: Signal<string> = signal<string>('Alice');
ts
// DO: let TypeScript infer
const count = signal(0);          // Signal<number>
const doubled = computed(() => count() * 2);  // ReadonlySignal<number>
const name = signal('Alice');     // Signal<string>

The exception is when the initial value does not capture the full type:

ts
// DO: annotate when inference falls short
const user = signal<User | null>(null);  // null alone infers Signal<null>
const items = signal<Item[]>([]);         // [] alone infers Signal<never[]>

Generic Components

Type component props with interfaces for maximum inference:

html
<script lang="ts">
interface Props {
  items: string[];
  onSelect: (item: string) => void;
}
</script>

For generic components in defineComponent, pass the type parameter:

ts
// A type-safe list component
function createList<T>() {
  return defineComponent<{
    items: T[];
    renderItem: (item: T) => AkashNode;
    keyFn: (item: T) => string | number;
  }>((ctx) => {
    return () => (
      <For each={ctx.props.items} key={ctx.props.keyFn}>
        {(item) => ctx.props.renderItem(item)}
      </For>
    );
  });
}

const UserList = createList<User>();

Utility Types for Props

Use TypeScript utility types to build prop interfaces cleanly:

ts
// Pick relevant fields from a larger type
interface UserCardProps {
  user: Pick<User, 'name' | 'avatar' | 'email'>;
  compact?: boolean;
}

// Omit fields you do not need
type CreateUserInput = Omit<User, 'id' | 'createdAt'>;

// Make all props optional for an update form
type UpdateUserInput = Partial<Omit<User, 'id'>>;

// Extract callback types
type OnSelect = (item: Item) => void;

Type-Safe Routes

Define route params as types and use them in loaders and pages:

ts
// routes/blog/[slug]/loader.ts
import type { RouteLoader } from '@akashjs/router';

interface BlogParams {
  slug: string;
}

interface BlogData {
  title: string;
  content: string;
  author: string;
}

export const loader: RouteLoader<BlogParams> = async ({ params, fetch }) => {
  return fetch(`/api/posts/${params.slug}`).then(r => r.json()) as Promise<BlogData>;
};
html
<!-- routes/blog/[slug]/page.akash -->
<script lang="ts">
import { useLoaderData, useParams } from '@akashjs/router';

interface BlogData {
  title: string;
  content: string;
  author: string;
}

const data = useLoaderData<BlogData>();
const params = useParams<{ slug: string }>();
</script>

<template>
  <article>
    <h1>{data()?.title}</h1>
    <p>By {data()?.author}</p>
    <div>{data()?.content}</div>
  </article>
</template>

Type-Safe Stores

Store types are inferred from the state() factory. You rarely need explicit type annotations:

ts
// Types are fully inferred
const useTaskStore = defineStore('tasks', {
  state: () => ({
    tasks: [] as Task[],
    filter: 'all' as 'all' | 'active' | 'done',
  }),
  getters: {
    // state is typed — state.tasks() returns Task[]
    filtered: (state) => {
      const f = state.filter();
      if (f === 'all') return state.tasks();
      return state.tasks().filter(t => t.done === (f === 'done'));
    },
    count: (state) => state.tasks().length,
  },
  actions: {
    // this is typed — this.tasks is Signal<Task[]>
    add(text: string) {
      this.tasks.update(list => [...list, { id: crypto.randomUUID(), text, done: false }]);
    },
    toggle(id: string) {
      this.tasks.update(list =>
        list.map(t => t.id === id ? { ...t, done: !t.done } : t)
      );
    },
  },
});

Type-Safe HTTP

The HTTP client methods are generic -- always pass the expected response type:

ts
// DON'T: untyped responses
const data = await http.get('/users');  // unknown

// DO: typed responses
const users = await http.get<User[]>('/users');          // User[]
const user = await http.post<User>('/users', payload);   // User

Type-Safe Context

ts
// The context type is inferred from the default value
const ThemeContext = createContext<'light' | 'dark'>('light');

// inject() returns 'light' | 'dark'
const theme = inject(ThemeContext);

Type-Safe Event Bus

ts
interface AppEvents {
  'user:login': { id: string; name: string };
  'user:logout': void;
  'cart:update': { itemCount: number };
}

const bus = createEventBus<AppEvents>();

// Fully typed — handler receives { id: string; name: string }
bus.on('user:login', (user) => {
  console.log(user.name);
});

// Type error: argument does not match 'user:login' payload
bus.emit('user:login', { wrong: 'type' }); // TypeScript error

Avoid any

ts
// DON'T
const data: any = await fetchData();
function process(input: any) { /* ... */ }
ts
// DO: use unknown and narrow
const data: unknown = await fetchData();

if (isUser(data)) {
  console.log(data.name); // typed as User
}

// Type guard
function isUser(val: unknown): val is User {
  return typeof val === 'object' && val !== null && 'name' in val;
}
ts
// DO: use generics instead of any
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

When you truly need escape hatches

If you are integrating with an untyped third-party library, use as unknown as TargetType rather than any. It forces you to be explicit about what you are doing.

Type-Safe Forms

The form field types are inferred from the initial values:

ts
const form = defineForm({
  email: { initial: '', validators: [required(), email()] },
  age: { initial: 0, validators: [min(18)] },
  agreed: { initial: false, validators: [] },
});

// form.values() returns { email: string; age: number; agreed: boolean }
// form.fields.email.value() returns string
// form.fields.age.value() returns number

With Zod, the types flow from the schema:

ts
const schema = z.object({
  email: z.string().email(),
  age: z.number().min(18),
});

const form = createFormFromSchema(schema);
// form.values() returns { email: string; age: number }

Released under the MIT License.