Tide

Schema (S) reference

The S builder, value types, validation, and Infer.

Workflow input is described with the global S builder. S is available without an import; each method returns a plain JSON-serializable descriptor. The runtime validates the caller's JSON input against the schema (after filling defaults) before main runs, and persists the input for deterministic replay.

Type definitions live in runtime/src/tide.d.ts for editor autocompletion.

The workflow convention

Every workflow export defaults { schema, main }:

const schema = S.object({ name: S.text() });

export default {
  schema,
  main: async (ctx: Infer<typeof schema>) => {
    log(ctx.name);
  },
};

defineWorkflow({ input, run }) is sugar over the same shape — see the Workflow API.

Value types

Tide's value types map to Argon's scalar types. Each builder returns a descriptor with a type (and a valueType):

BuilderAccepts (input JSON)Infer type
S.text()a stringstring
S.integer() / S.int()an integernumber
S.nat()a non-negative integernumber
S.decimal()an integer/decimal as number or numeric stringstring | number
S.real()a number or numeric stringstring | number
S.money(){ amount, currency } (currency is an ISO 3-letter code){ amount, currency }
S.date()an ISO date string YYYY-MM-DDstring
S.dateTime()an ISO datetime stringstring
S.boolean()true / falseboolean
S.iri()a non-empty stringstring
S.conceptRef()a non-negative integer idnumber
S.individualRef()a non-negative integer idnumber
S.text()      // { type: "text", valueType: "Text" }
S.integer()   // { type: "integer", valueType: "Integer" }
S.money()     // { type: "money", valueType: "Money" }

Composition

Objects

S.object({
  name: S.text(),
  age: S.integer(),
  bio: S.optional(S.text()),
})

S.object automatically tracks which fields are required: a field is required unless it is wrapped in S.optional(...) or carries an S.default(...).

Lists and sets

S.list(S.text())          // an ordered array of strings
S.set(S.integer())        // a set of integers (an array on the wire)
S.list(S.object({ ... })) // a list of objects

Optional and nullable

S.optional(S.text())             // the field may be omitted (string | undefined)
S.nullable(S.text())             // the field may be null (string | null)
S.optional(S.nullable(S.text())) // string | null | undefined

optional means the field may be absent from the input. nullable means it may be present as null.

Defaults

S.default(S.integer(), 3)                       // omit it → arrives as 3
S.default(S.enum(["low", "medium", "high"]), "medium")

A defaulted field may be omitted on input; the default is filled in before validation, so main always sees a value. (Defaulted object fields are therefore always present in the Infer type, unlike plain optional fields.)

Literals and enums

S.literal("active")                  // exactly "active"
S.literal(42)                        // exactly 42
S.enum(["pending", "active", "done"]) // one of the listed values
S.enum([1, 2, 3])                     // one of the listed numbers

Unions

S.union([S.text(), S.integer()])     // string | number
S.union([
  S.object({ kind: S.literal("a"), value: S.text() }),
  S.object({ kind: S.literal("b"), value: S.integer() }),
])

A value validates if it matches at least one variant.

Nested composition

Schemas compose freely:

const address = S.object({
  street: S.text(),
  city: S.text(),
  zip: S.text(),
});

const schema = S.object({
  name: S.text(),
  address,
  shippingAddress: S.optional(address),
});

Type inference with Infer

Infer<T> is a global compile-time type that maps a schema descriptor to its TypeScript type — full editor autocompletion, zero runtime cost.

const schema = S.object({
  name: S.text(),
  count: S.integer(),
  priority: S.default(S.enum(["low", "medium", "high"]), "medium"),
  tags: S.optional(S.list(S.text())),
  config: S.object({
    retries: S.default(S.integer(), 3),
    verbose: S.optional(S.boolean()),
  }),
});

// Infer<typeof schema> resolves to:
// {
//   name: string;
//   count: number;
//   priority: "low" | "medium" | "high";   // defaulted → always present
//   tags?: string[];                        // optional → may be undefined
//   config: {
//     retries: number;                      // defaulted → always present
//     verbose?: boolean;
//   };
// }

Validation

Validation runs in JavaScript before main, after defaults are applied. It walks the descriptor and collects every error with a JSON-path to the offending field. A failing run reports all errors at once:

Input validation failed:
$.name: expected Text string, got number 123
$.count: required but missing
$.config: required but missing

What is checked:

  • Value type per the table above (text, integer, nat, decimal, real, money, date, datetime, boolean, iri, conceptRef, individualRef).
  • Required fields — every non-optional, non-defaulted object property must be present.
  • Unknown keys — extra object properties are rejected.
  • Literal / enum — exact match against the allowed value(s).
  • List / set items — each element validated against the item schema.
  • Union — at least one variant matches.
  • Optional / nullable — missing/undefined allowed for optional; null allowed for nullable.

Empty schemas

A workflow that needs no input uses an empty object:

const schema = S.object({});

export default {
  schema,
  main: async (_ctx: Infer<typeof schema>) => {
    log("no input needed");
  },
};
tide run workflow.ts   # no --input flag needed

Input persistence

Input is persisted per invocation for deterministic replay:

  • First run — the input is saved alongside the journal and timestamp.
  • Replay — the stored input is loaded automatically; you do not pass --input again.
  • Mismatch — passing a different --input on replay prints a warning and uses the stored input.

Provide input with --input '<json>' or --input-file <path> (mutually exclusive). See the CLI reference.

On this page