TinyBase logoTinyBase β

Schema-Based Typing

You can use type definitions that infer API types from the schemas you apply, providing a powerful way to improve your developer experience when you know the shape of the data being stored.

The schema-based definitions can be accessed by adding the with-schemas suffix to your imports. For example:

import {createStore} from 'tinybase/with-schemas';

// NB the 'with-schemas'

const store = createStore().setValuesSchema({
  employees: {type: 'number'},
  open: {type: 'boolean', default: false},
});

store.setValues({employees: 3}); //                      OK
store.setValues({employees: true}); //                   TypeScript error
store.setValues({employees: 3, website: 'pets.com'}); // TypeScript error

In this example, the store is known to have the ValuesSchema provided, and all relevant methods will have type constraints accordingly, even for listeners:

store.addValueListener(null, (store, valueId, newValue, oldValue) => {
  valueId == 'employees'; // OK
  valueId == 'open'; //      OK
  valueId == 'website'; //   TypeScript error

  if (valueId == 'employees') {
    newValue as number; //   OK
    oldValue as number; //   OK
    newValue as boolean; //  TypeScript error
    oldValue as boolean; //  TypeScript error
  }
  if (valueId == 'open') {
    newValue as boolean; //  OK
    oldValue as boolean; //  OK
  }
});

Getting the Typed Store

Only the setSchema method, setTablesSchema method, and setValuesSchema method return a typed Store object. So, to benefit from the typing, ensure you assign your Store variable to what those methods return, rather than just the createStore function.

For example, the following will work at runtime, but you will not benefit from the developer experience of typing on the store variable as we did in the example above.

import {createStore} from 'tinybase/with-schemas';

const store = createStore(); // This is not a schema-typed Store

store.setValuesSchema({
  employees: {type: 'number'},
  open: {type: 'boolean', default: false},
}); // Instead you should use the return type from this method

One further thing to be aware of is that for the typing to work effectively, the schema must be passed in directly, or, if it is a variable, as a constant:

const valuesSchema = {
  employees: {type: 'number'},
  open: {type: 'boolean', default: false},
} as const; // NB the `as const` modifier
store.setValuesSchema(valuesSchema);

It's worth noting that typing will adapt according to schemas being added, removed, or changed:

const tablesSchema = {
  pets: {species: {type: 'string'}},
} as const;

const valuesSchema = {
  employees: {type: 'number'},
  open: {type: 'boolean', default: false},
} as const;

const store = createStore();
const storeWithBothSchemas = store.setSchema(tablesSchema, valuesSchema);
const storeWithJustValuesSchema = storeWithBothSchemas.delTablesSchema();
const storeWithValuesAndNewTablesSchema = storeWithBothSchemas.setTablesSchema({
  pets: {
    species: {type: 'string'},
    sold: {type: 'boolean', default: false},
  },
});

Typing The ui-react Module

Schema-based typing for the ui-react module is handled a little differently, due to the fact that all of the hooks and components are top level functions in the module. It would be frustrating to apply a schema to type each and every one in turn.

Instead, you can use the WithSchemas type (which takes the typeof the schemas), and the following pattern after your import. This applies the schema types to the whole module en masse, and then you can select the hooks and components you want to use:

import React from 'react';
import * as UiReact from 'tinybase/ui-react/with-schemas';
import {createStore} from 'tinybase/with-schemas';

const tablesSchema = {
  pets: {species: {type: 'string'}},
} as const;
const valuesSchema = {
  employees: {type: 'number'},
  open: {type: 'boolean', default: false},
} as const;

// Cast the whole module to be schema-based with WithSchemas:
const UiReactWithSchemas = UiReact as UiReact.WithSchemas<
  [typeof tablesSchema, typeof valuesSchema]
>;
// Deconstruct to access the hooks and components you need:
const {TableView, useTable, ValueView} = UiReactWithSchemas;

const store = createStore().setSchema(tablesSchema, valuesSchema);
const App = () => (
  <div>
    <TableView store={store} tableId="species" /> {/*   OK               */}
    <TableView store={store} tableId="customers" /> {/* TypeScript error */}
    {/* ... */}
  </div>
);

Note that in React Native, the resolution of modules and types isn't yet quite compatible with Node and TypeScript. You may need to try something like the following to explicitly load code and types from different folders:

// code
import React from 'react';
import * as UiReact from 'tinybase/ui-react';
import type {WithSchemas} from 'tinybase/ui-react/with-schemas';
// types
import {TablesSchema, ValuesSchema, createStore} from 'tinybase/with-schemas';

const tablesSchema = {
  pets: {species: {type: 'string'}},
} as const;
const valuesSchema = {
  employees: {type: 'number'},
  open: {type: 'boolean', default: false},
} as const;

const UiReactWithSchemas = UiReact as unknown as WithSchemas<
  [typeof tablesSchema, typeof valuesSchema]
>;

//...

Multiple Stores

In the case that you have multiple Store objects with different schemas, you will need to use WithSchemas several times, and deconstruct each, something like this:

const UiReactWithPetShopSchemas = UiReact as UiReact.WithSchemas<
  [typeof petShopTablesSchema, typeof petShopValuesSchema]
>;
const {
  TableView: PetShopTableView,
  useTable: usePetShopTable,
  ValueView: usePetShopValueView,
} = UiReactWithPetShopSchemas;

const UiReactWithSettingsSchemas = UiReact as UiReact.WithSchemas<
  [typeof settingsTablesSchema, typeof settingsValuesSchema]
>;
const {
  TableView: SettingsTableView,
  useTable: useSettingsTable,
  ValueView: useSettingsValueView,
} = UiReactWithSettingsSchemas;

const petShopStore = createStore().setSchema(
  petShopTablesSchema,
  petShopValuesSchema,
);
const settingsStore = createStore().setSchema(
  settingsTablesSchema,
  settingsValuesSchema,
);
const App = () => (
  <div>
    <PetShopTableView store={petShopStore} tableId="species" />
    <SettingsTableView store={settingsStore} tableId="viewSettings" />
    {/* ... */}
  </div>
);

Defining Schema-Based Stores in React Components

One final pattern to be aware of is that you can use the WithSchemas type to create a schema-based Store in a non-rendering React component. This allows you to create a Store with a schema, define its persistence behavior, publish it into the context, and even expose domain-specific hooks - all in a single self-contained file.

There are good examples of this in the TinyHub project (see the store components this folder).

If you want to use this pattern your app's top-level will look something like this:

export const App = () => {
  return (
    <Provider>
      <MyStore /> {/* This is a schema-based Store component */}
      <ActualApp /> {/* This is the actual rendered app */}
    </Provider>
  );
};

The MyStore component renders null so it doesn't appear visually, but it is responsible for creating the Store and making it available to the rest of the app via the Provider context that wraps them both.

The MyStore.tsx file might look something like this. (In this simplified case, we are just typing key values in our Store but of course this would work for a tabular Store too.)

// A unique Id for this Store.
const STORE_ID = 'myStore';

// The schema for this Store.
const VALUES_SCHEMA = {
  myStringValue: {type: 'string', default: 'foo'},
  myNumericValue: {type: 'number', default: 42},
} as const;
type Schemas = [NoTablesSchema, typeof VALUES_SCHEMA];

// Destructure the ui-react module with the schema applied.
const {useCreateStore, useProvideStore, useCreatePersister, useValue} =
  UiReact as UiReact.WithSchemas<Schemas>;

export const MyStore = () => {
  // Create the Store and set its schema
  const myStore = useCreateStore(() =>
    createStore().setValuesSchema(VALUES_SCHEMA),
  );

  // Create a local storage persister for the Store and start it
  useCreatePersister(
    settingsStore,
    (settingsStore) => createLocalPersister(settingsStore, STORE_ID),
    [],
    async (persister) => {
      await persister.startAutoLoad();
      await persister.startAutoSave();
    },
  );

  // Provide the Store for the rest of the app.
  useProvideStore(STORE_ID, settingsStore);

  // Don't render anything.
  return null;
};

In that same file, you can also define domain-specific hooks that use an expose the schema. For example, this hook will be typed to return a string if passed 'myStringValue', and a number if passed 'myNumericValue'.

type ValueIds = keyof typeof VALUES_SCHEMA;
export const useSettingsValue = <ValueId extends ValueIds>(valueId: ValueId) =>
  useValue<ValueId>(valueId, STORE_ID);

This means that the rest of the app can use Values from the Store with correct types, and the implementation details of the Store are encapsulated in the single file. For more complex applications, it really helps to keep everything about the Store in one place.

Summary

Schema-based typing provides a powerful developer-time experience for checking your code and autocompletion in your IDE. Remember to use the with-schema suffix on the import path and use the patterns described above.

We move on to discussing more complex programmatic enforcement of your data, and for that we turn to the Mutating Data With Listeners guide.