Skip to content

Safety by construction

The value, operator, mutability, and boundary rules that make common JavaScript mistakes explicit.

Updated View as Markdown

Hem disallows several common JavaScript failure modes at compile time. These rules require values and operations to be stated explicitly.

Booleans and equality

Conditions, prefix logical !, &&, and || require boolean operands. Non-boolean values such as numbers, strings, arrays, or objects cannot be used as conditions; write explicit comparisons or presence checks instead. Inside an Effect block, postfix ! is a bind operator, not logical negation.

let hasItems = items.length > 0
if hasItems && enabled { flush(items) }

== compiles to JavaScript’s strict equality (===), and != compiles to strict inequality (!==). Loose equality is not supported. The only exception is comparison with None: value == None and value != None compile to value == null and value != null, checking for both null and undefined in a single evaluation.

Numeric operators

Binary + and += perform arithmetic only. Both operands must be number, or both must be bigint. String concatenation and mixed numeric operations are rejected with error HEM2021. For string concatenation, use template literals. Unary + also requires a number operand and does not perform type coercion.

let total = price * quantity
let message = `Total: ${total}`

This prevents unintentional type conversions from runtime string values and requires explicit conversions, such as Number(value).

Absence and checked indexing

Hem refuses authored any types; exact null and undefined types retain their TypeScript meaning. In core Hem their value literals remain refused; bounded class members retain native absence values. ?T represents the unboxed optional type T | null | undefined for Hem types. Array index access is checked: under noUncheckedIndexedAccess, items[i] may be absent. Check the returned value before using it; a bounds check alone does not establish its presence.

let names: string[] = ["Ada"]
let first: ?string = names[0]
let greeting = match first {
| Some(name) => `Hello, ${name}`
| None => "Hello"
}

Do not use ?T to widen external T | null or T | undefined types. Write the exact union directly in Hem, or reuse the external library’s exported type.

Postfix ! does not assert that a value is present. HEM2004 suggests handling absence, capturing and checking the read, or requiring a stronger input type. Its Promise and Effect guidance is conditional: awaiting a Promise does not prove that its resolved value is present. Repairs must preserve evaluation order and count, so no automatic rewrite is offered.

Binding and value mutability

let creates an immutable binding and compiles to const. let mut permits replacing the binding’s value. It does not make the value’s fields or elements writable.

Object properties and class fields are readonly by default. Declare a field with mut to permit writes:

let point: { x: number, mut y: number } = { x: 1, y: 2 }
point.y += 1

Array and tuple types are readonly by default. Use mut T[] or mut [A, B] for writable storage. For nested arrays, mut (T[])[] permits changing the outer array; (mut T[])[] permits changing the inner arrays. Global Array<T> annotations also receive readonly views; imported aliases retain their declared permissions.

Tuple mutation must preserve required positions and declared slot types. HEM2033 refuses destructive methods, length writes, and indexed writes that cannot establish this. Compatible element replacement and direct appends to a required-prefix tuple with a compatible rest element remain available. Extracting an unsafe mutator does not make it safe. HEM2030 also refuses exposing a tuple through a mutable-array interface that could remove its required head. Use an ordinary mutable array when freely changing length is intended.

An application can describe nonemptiness with an ordinary readonly tuple alias and construct it from a known first value. Copy explicitly when a result must be independent of its source container; a shallow copy preserves existing element identities. Native map and filter keep their usual types and runtime behavior, and filtering can return an empty array. Hem adds no collection runtime module, ownership marker, automatic copy, or special nonempty inference.

Fresh values and existing aliases

Inferred record and array literals receive readonly views. Fresh children receive their own readonly views; existing children keep their declared permissions and identity. This is not general deep immutability.

Element and field types widen normally. An empty inferred array has element type never; annotate its element type when needed. An explicit mutable contract permits fresh mutable construction.

For if, match, and try results, fresh branches receive their views before their types are combined. Selecting an existing mutable value preserves its permissions. A local binding does not inherit an enclosing callback’s mutable return contract.

Spreads retain the permissions of existing children. Later fields replace earlier fields in source order. Destructuring rest creates a new container with a readonly view; it does not freeze or copy the objects stored inside it.

These rules also apply in the supported class subset. Class member bodies use TypeScript-shaped syntax; they do not accept all Hem expression forms. Boolean conditions, numeric +, foreign any demotion, immutable plain let, assertion refusal, and must-use apply to methods, initializers, and nested callbacks. let mut enables local mutation and let _ = expression explicitly discards a value. See TypeScript interoperability.

Sharing does not grant write permission

HEM2030 rejects checked transfers that make an existing readonly value writable or widen the writes accepted by an existing mutable value. This includes nested record properties, array elements, tuple slots, and indexed records.

A readonly parameter is a view, not a copy or freeze. Other aliases can still change the object. Passing a readonly array to a library parameter declared mutable is refused, even if you believe the function only reads it. Use a supported readonly API or explicitly construct fresh mutable storage when copying is appropriate. A new outer container does not remove the write restrictions of shared children.

Generic constraints do not establish permission to write every possible instantiation. Keep the generic identity type, expose a readonly view, or construct fresh storage from checked values.

These checks are not an ownership system or a proof of every TypeScript generic, mapped-type, or foreign-object relationship. Imported declarations remain trusted contracts.

Property checks and intervening calls

HEM2031 reports a property read that relies on a check an intervening operation may have invalidated. Even a function with readonly parameters can mutate another alias.

Check the property again after the operation, or capture the checked primitive before it. Capturing the object itself does not preserve the values of its properties. A recheck must guard the later read; ignoring its result does not restore evidence.

Calls are not the only operations that can execute user code. Object spreads can invoke getters or proxy traps; iteration and destructuring can invoke iterators and defaults. Property writes can invalidate checks through shared aliases. Evaluation order matters: a primitive read before a call is already captured, but a property read after that call needs current evidence.

Branches and loops preserve evidence only on paths that justify the read. Catch handlers need fresh checks. Cleanup can invalidate evidence before control reaches its destination.

These checks cover property and indexed reads in supported control-flow forms, not every closure or dynamic access pattern. TypeScript may also reject a read independently. HEM2031 offers guidance rather than an automatic edit: moving a read earlier can change program behavior.

Values must be used

Hem reports diagnostic HEM2018 when an expression statement produces a value that is discarded without being used. This applies to pure expressions (such as comparisons) and calls that return carriers: Promise, Effect, canonical Option or Result, or configured boxed carriers. Calls with void or side-effecting return types, such as console.log, array.push, and map.set, are permitted.

let saved = await save(document)
let _ = metrics.record("saved")

To intentionally discard a value, use let _ = expression. This compiles to void (expression);, evaluating the expression while marking the result as deliberately discarded. Must-use checking catches accidental omission; explicit discard acknowledges the choice rather than proving that the work completed safely.

Discard does not await a Promise or handle its rejection. A discarded Promise can still reject without a handler. Await it, handle its rejection, or give it to an explicit supervision mechanism when completion matters. Discarding an Effect description does not run it, and discarding a Result does not handle its failure.

Type assertions need checked evidence

The HEM2028 check includes assertions in class method bodies and field initializers.

Project checking rejects assertions that turn unknown into a concrete type, including the narrowing step in value as unknown as string (HEM2028). Use a checked condition or runtime decoder instead. Widening and literal as const assertions remain available; satisfies checks conformance without supplying unchecked evidence. Pattern aliases are unaffected.

An as or satisfies expression cannot itself be an assignment or update target (HEM2032). This includes compound assignments, destructuring targets, and for...in or for...of targets in class bodies. For example, (value as number) = 2 cannot bypass a const binding. Assign to the original binding or property so its mutability and accepted type are checked. Assertions used to read values, including computed assignment keys, remain subject to the usual evidence checks.

These checks do not establish universal soundness. Imported declarations remain trusted contracts, and foreign code can violate those contracts.

Foreign values become unknown

Hem uses TypeScript for type checking, but expressions typed as any are converted to unknown at the boundary. The compiler inserts a cast and reports diagnostic HEM2001. Values typed as unknown must be narrowed using typeof, instanceof, type predicates, or match before they can be used.

What these rules prevent

  • A truthy number or string taking an unintended branch.
  • Loose equality converting unrelated values before comparing them.
  • String concatenation or mixed number/bigint arithmetic hiding a type error.
  • An unchecked array read being used as if an element were guaranteed.
  • A shared record or class field changing without an explicit mutable declaration.
  • A dropped promise, carrier, or comparison concealing missing work.
  • An any value escaping a foreign boundary without a narrowing step.

Normative source

Specification 0.2, §3 (types), §4 (bindings), §6 (operators and must-use), §10 (absence), §13 (interop), and §15 (removed constructs).

From TypeScript practices to Hem

The TypeScript practices guide and its patterns mix type guarantees with application and team choices. Hem preserves that distinction:

Practice Hem contract
Discriminated states Variant declarations and structural unions support checked narrowing; choosing a useful domain model remains the author’s job. Optional-field records remain legal.
Domain brands Declaration-owned identity prevents independent brands from mixing. Construction returns the original value; it does not validate it.
Constructive models Required tuple positions express a known first element. Supported mutations and writable transfers cannot erase that guarantee.
Simplest total input Keep ordinary arrays for operations that handle emptiness; require a tuple only where a first element is needed. No collection runtime is added.
External unknowns Foreign any expressions become unknown; narrow or parse before use.
Existing schemas Prefer the application’s schema library and infer its output type. Hem generates no validators.
Assertions HEM2028 refuses unchecked narrowing, including unknown to a concrete domain. Widening and as const remain available.
Narrowing Match discriminants, in, typeof, and instanceof use TypeScript’s checked facts. Captured reads can preserve evidence across calls.
Predicates Authored is/asserts signatures are refused; checked inference and imported predicates remain available. Foreign implementations are trusted.
Complete matches Supported closed domains need every case or an intentional fallback. Missing cases error; redundant arms warn.
Conformance satisfies checks a value without authorizing an unchecked narrowing cast.
Boundary parsing Prefer a public type and checked parser with a local constructor. Explicit constructor exports remain legal. Boundary placement and wire-version policy belong to the application.
Reusing types Schema inference and ordinary utility types remain available; avoiding duplicate models is an authoring preference.
Named arguments Object parameters are available and preferred where they clarify calls; positional parameters remain legal.
Real verification Compiler, emitted-runtime, declaration, installed-package, and browser checks establish different claims. This is a development practice, not a syntax rule.
Telemetry Structured logging and useful context remain application policy. Hem does not prohibit console.log.

A required head proves items[0], not an arbitrary numeric index. A plain numeric duration can still be negative. Choose validation and construction that establish the actual domain constraint instead of assuming a type name proves it.

The recurring invoice preview demonstrates the validated-domain recipe with React, Zod, and date-fns. Its schema creates local amount/count brands, the schedule constructs its first item explicitly, and its success/error UI uses a complete structural match. Readonly describes access; an independent snapshot still needs an explicit shallow copy, and that copy retains references to nested objects. Foreign declarations remain trusted contracts.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close