Skip to content

TypeScript

Alinea infers TypeScript types from your schema, so there are no types to generate or keep in sync. Query results are typed automatically, and Infer gives you the types to use in your components.

Query results

The result of a query follows from its type and select:

app/blog/page.tsx
import {cms} from '@/cms'
import {BlogPost} from '@/schema/BlogPost'
import {Query} from 'alinea'

const posts = await cms.find({
  type: BlogPost,
  select: {
    title: Query.title,
    url: Query.url,
    publishDate: BlogPost.publishDate
  }
})
// Array<{title: string, url: string, publishDate: string}>

Without select you get all fields of the type plus the entry fields: _id, _type, _url, _locale, _parentId and so on.

To pass a result to a component, derive its type from the function that fetches it:

async function fetchPosts() {
  return cms.find({
    type: BlogPost,
    select: {title: Query.title, url: Query.url}
  })
}

type PostSummary = Awaited<ReturnType<typeof fetchPosts>>[number]

Infer types from the schema

Infer turns a type, a list schema or a field into the shape queries return:

schema/BlogPost.ts
import {Config, Field, type Infer} from 'alinea'

export const TextBlock = Config.type('Text', {
  fields: {
    body: Field.richText('Body')
  }
})

export const ImageBlock = Config.type('Image', {
  fields: {
    image: Field.image('Image')
  }
})

export const BlogPost = Config.document('Blog post', {
  fields: {
    publishDate: Field.date('Publish date'),
    author: Field.entry('Author'),
    blocks: Field.list('Blocks', {
      schema: {Text: TextBlock, Image: ImageBlock}
    })
  }
})

// The fields of a blog post, as a query returns them
export type BlogPost = Infer<typeof BlogPost>
// The same, plus the entry fields (_id, _type, _url, ...)
export type BlogPostEntry = Infer.Entry<typeof BlogPost>
// A row of the list field, with _id, _index and _type
export type TextBlock = Infer.ListItem<typeof TextBlock, 'Text'>
// The value of one field
export type Blocks = BlogPost['blocks']

Giving the type and the constant the same name, as above, lets you import both with one name: BlogPost is the schema in queries and the inferred type in annotations.

  • Infer<T>: the query value of a type, a field or a list schema ({Text: TextBlock, ...}, which becomes a union of rows).

  • Infer.Entry<T, Name>: Infer<T> plus the entry fields. Pass the type name to narrow _type.

  • Infer.ListItem<T, Name>: Infer<T> plus the list row fields _id, _index and _type.

  • Infer.Stored<T>: the stored value, before links are resolved. This is what Edit.create, Edit.update and custom field views work with.

Narrow list rows by _type

List rows carry their type name in _type, so a switch narrows each row to its own fields:

components/Blocks.tsx
import type {Blocks} from '@/schema/BlogPost'

export function BlocksView({blocks}: {blocks: Blocks}) {
  return (
    <>
      {blocks.map(block => {
        switch (block._type) {
          case 'Text':
            return <TextBlockView key={block._id} block={block} />
          case 'Image':
            return <img key={block._id} src={block.image?.src} alt="" />
        }
      })}
    </>
  )
}

Good to know

  • Types describe your current schema. Content files written before you added a field don't have it yet: alinea build --fix fills in the defaults of missing fields in every file.

  • Infer types from the schema constants you query with. Types written by hand drift from the schema without the compiler noticing.