Skip to main content

Idiomatic SoundScript

This guide highlights the patterns that keep soundness obvious without making code noisy.

For the mechanical before/after patterns behind the most common diagnostics, see Common Rewrites.

Readonly First

Prefer readonly inputs when you do not intend to mutate a value.

Readonly inputs reduce invariance friction, make helper APIs easier to compose, and make it clearer when a function is taking a snapshot rather than owning mutable state.

function summarizeUsers(users: readonly User[]): string {
return users.map((user) => user.name).join(', ');
}

When mutation is required, copy first and mutate the fresh object or array.

Capture Before await

If you need a narrowed property after an await, helper call, or callback boundary, capture the stable value before the boundary and use that local afterward.

async function reportRun(childRun: { status: 'running' | 'completed' | 'failed' }) {
const status = childRun.status;
await flushLogs();

if (status === 'completed') {
return 'done';
}

return 'pending';
}

Validate At The Boundary

Service code should convert raw input into a checked shape as early as possible.

Use decoders for structured input, and keep the service layer focused on business rules once the shape is honest.

import { decodeJson } from 'sts:json';
import { object, optional, string } from 'sts:decode';

const UserDecoder = object({
id: string,
nickname: optional(string),
});

export function parseUser(text: string) {
return decodeJson(text, UserDecoder);
}

Decoder Patterns

Keep decoders at the boundary and let the rest of the module work on already-checked shapes.

The most reusable shape is:

  1. decode the raw input
  2. convert the decoded data into the domain type
  3. keep the service layer free of casts
import { decodeJson } from 'sts:json';
import { defaulted, nullable, object, readonlyRecord, string } from 'sts:decode';

const RequestDecoder = object({
metadata: defaulted(readonlyRecord(string), {}),
nickname: nullable(string),
userId: string,
});

export function parseRequest(text: string) {
return decodeJson(text, RequestDecoder);
}

Use nullable(...) for explicit null-bearing fields, defaulted(...) for fields that should be filled in at the boundary, and readonlyRecord(...) when the decoded shape should stay readonly through the service layer.

Try Versus isErr

Use Try(...) when you are already in a Result-returning flow and want the early-return behavior.

import { Try, ok, type Result } from 'sts:prelude';

declare function loadUser(): Result<User, Error>;

function loadName(): Result<string, Error> {
const user = Try(loadUser());
return ok(user.name);
}

Use isErr(...) when you want to branch manually, inspect the error, or work in a shape that does not fit the macro's return-contract expectations.

import { isErr, ok, type Result } from 'sts:prelude';

function formatCount(input: string): string {
const parsed: Result<number, Error> =
Number.isFinite(Number(input)) ? ok(Number(input)) : { tag: 'err', error: new Error('bad count') };

if (isErr(parsed)) {
return parsed.error.message;
}

return String(parsed.value);
}

JSON Boundaries

Treat JSON as an explicit boundary, not as a place to smuggle unchecked data through the type system.

Use JsonValue for plain JSON and JsonLikeValue when the data can still contain bigint or undefined before encoding.

import { isJsonValue, parseJson, type JsonValue } from 'sts:json';

function loadRecord(text: string): JsonValue | undefined {
const parsed = parseJson(text);
if (parsed.tag === 'err') {
return undefined;
}

return isJsonValue(parsed.value) ? parsed.value : undefined;
}

For service layers, the useful sequence is usually:

  1. parse the input
  2. validate the structure
  3. convert to the domain type
  4. keep the rest of the module free of casts

Escape Hatches

Use escape hatches only at the edge of the program, in tests, or at places where the invariant is already enforced by an earlier boundary.

import { unwrapOrThrow } from 'sts:result';

const config = unwrapOrThrow(loadConfig());
  • unwrapOrThrow(...) is appropriate for CLI entrypoints, tests, and startup code that should fail fast.
  • todo(...) is for intentionally unfinished branches that should stop execution loudly.
  • unreachable(...) is for states that have already been ruled out by an earlier proof or total match.

A Practical Rule Of Thumb

If a helper can stay small, readonly, and local, it usually should.

That keeps the sound path obvious and avoids growing a general-purpose utility layer that nobody can remember.