Built. typl has a working toolchain in this repository: the compiler accepts every construct below, and ridl fmt, the LSP server and the code generators are built on it. See what is built.

typl Language Reference

Type Language — the vocabulary layer of the RIDL family: types, ranges, units, constants, and composites, with package namespacing. typl is the root of the family lattice — every other profile (ridl, rmdl, rsdl) builds on the vocabulary typl declares.

Version: 0.1.0

Provenance. This reference re-frames Language Reference §1–§11 of the RIDL Language Reference as the named foundation layer (per the RIDL family concept note, §7.1 Stage 1), incorporates the module system of ADR-0002, and mines the preliminary markspec typl DSL as prior work (see Appendix F). Where this document and the RIDL Language Reference v0.1 overlap, this document is authoritative for the vocabulary layer; the RIDL Language Reference retains authority for interactions.


Table of Contents

  1. Scope and Position in the Family
  2. Lexical Conventions
  3. Packages, Imports, and Visibility
  4. Primitives
  5. Type Definitions
  6. Constants
  7. Structs
  8. Enums
  9. Enum Sets
  10. Unions
  11. Tuples
  12. Collections
  13. Comments
  14. Doc Comments
  15. Conventions
  16. Diagnostics
  17. Open Questions

1. Scope and Position in the Family

1.1 What typl is

typl is a units-aware, range-constrained schema language. It answers one question: what is the shared vocabulary? — the types, physical units, ranges, constants, and composite shapes that every other concern in a component-based reactive system describes things with.

typl is the only truly standalone member of the RIDL family. A pure typl package is useful by itself: it generates data types, validators, and documentation across every backend without any interface, behaviour, or deployment content. Every other profile requires typl; typl requires nothing above it.

The name is deliberately -L, not -DL: typl does not describe a system aspect — it is the vocabulary the describing languages are written in.

1.2 The .typl profile

There is one grammar for the whole RIDL family; each language is a profile — a restriction of that grammar selected by file extension. A .typl file accepts type declarations only:

Accepted in .typlRejected in .typl (belongs to)
package, import, as, internalinterface, signal, event, command, query, fixed (ridl)
type, constinteraction declarations (ridl, and the rxdl spellings)
struct, enum, enumset, unionmodel, node, behaviour operators (rmdl)
tuples, collections, ? optionalityinstances, wiring, deployment (rsdl)
doc comments, @labelstiming annotations @Xms, @[min..max] (time core)
streams <T> (ridl — see §1.3)

typl declarations are also legal inside every other profile's files and inside .rxdl (the total profile) — a .ridl file may declare the types its interface uses. Profile purity is a policy decision declared per package in ridl.toml, not a grammar limitation (concept note §4).

1.3 Profile boundary decisions

Three boundary calls are recorded here because they were genuinely undecided:

Streams belong to ridl, not typl. A stream <T> is not a data shape — it has no bound, its framing is transport-decided, and it is only valid in interaction position (command/query parameters and returns). That makes it an interaction container, not a vocabulary item. typl owns the element type (type FwBlock : bytes [1..4096]); ridl owns the <T> container. A <T> appearing in a .typl file is a profile error (TYPL-301).

Timing is not typl. Duration literals (10ms, @[20ms..1s]) belong to the family's time core, consumed by ridl/rmdl. The family lexer recognises duration tokens everywhere (one lexer, one registry), but no typl construct accepts them. The stdlib Duration type (a UCUM ms unit type) is unaffected — it is an ordinary unit type.

Interaction kinds are not typl kinds. The markspec-typl precursor attached kinds (signal, event, command, state, stream, config, document) to type bindings. In the family these are split by layer: signal/event/command/query/fixed are ridl's interaction keywords over the interact core; state and stream processing are rmdl territory; config maps to ridl fixed (provisioned constants); document is just a struct. typl keeps only the pure vocabulary kinds: type, const, and the composites. See Appendix F.

1.4 Keyword discipline

The family maintains one reserved-word registry across all profiles (concept note §4.1). Every family keyword is reserved in every profile, including keywords a profile does not accept — signal is not a valid identifier in a .typl file even though .typl rejects signal declarations. This keeps .rxdl unambiguous and keeps identifiers portable across layers.

Keywords used by the typl profile:

package  import  as  internal
type  const  struct  enum  enumset  union
boolean  integer  float  string  bytes
true  false  step  match  reserved  error

Init values use bare = value (§5.8), not a keyword — type Speed : km/h [0.0..250.0 step 0.5] = 0.0, the universal default-value idiom. There is no default keyword (retired) and no init keyword in typl (init is rmdl's, where two equations per flow must be disambiguated). The wire clause and explicit width names (uint8…float64) are deferred to v0.2 (§17.11): range-first inference plus ridl-diff breaking-change detection cover the common case, and forward-compat width-pinning is a niche refinement not worth ten keywords in v0.1.

Keywords reserved family-wide but rejected by .typl (the registry as built, crates/ridl-syntax/src/keywords.rs): ridl's interface, service, signal, event, command, query and fixed (family-neutral, ADR-0012 decision 4); the expr-core words require, ensure; the ridl clause words states and during (general form §4.8); rmdl's model, function, let, init, last, case, if, then, else, when, emit (it also surfaces signal/event in signatures — same concepts, one registry entry each; init is rmdl's alone — the memory-seed keyword, needed because a flow has two equations; typl/ridl express init as bare = value; its ambient time values now/dt are contextual identifiers, not keywords; pre, ->-as-followed-by, node, returns, realizes, and a surface step considered and rejected — models are contract-blind, so no realizes; step remains typl's quantization keyword alone), plus its reserved set merge, current, state, transition, automaton; and rsdl's system, component, distribution, deployment, machine, offers, requires, for (rsdl reference §2 — the rsdl v0.1 words provides, instance, assurance, target, place, on, transport, bundle, time, base, redundant, supervise and degraded left the registry with rsdl v0.2, because no other profile uses them, and each is an ordinary identifier in every profile). (node and returns were considered and rejected by rmdl — never reserved.) The per-profile keyword sections of each language reference enumerate their own additions; the union of those sections is the registry until the platform spec extracts it as a standalone normative list.

The domain spellings are not reserved words — the member-start rule (ADR-0012 decision 4, as amended on 2026-10-11). present, notify, measure, detect, actuate, trigger, the intent operation shapes activate, toggle, select, adjust, dismiss, and the open candidates supply and read are keywords only as the first token of an interface member, in a profile that implements the spelling; no profile does in this release. Everywhere else each is an identifier: the ROS 2 corpus names a field present (evals/corpus/ros2/sensor_msgs/messages.typl) and a query trigger (evals/corpus/ros2/std_srvs/interactions.ridl). An interface member line never starts with an identifier, so a profile that gains a spelling invalidates no source that compiled before. The uxdl words ADR-0012 retired — view, display, input, action, fetch, navigate, scroll, drag, observe, surface, agent — are ordinary identifiers in every profile.

Registry admission test — language, never runtime (family doctrine, audit passed). Every registry entry names a describable property — a shape, an interaction, a requirement, an availability, an occurrence relation — never an execution mechanism. Steps, scheduling, acks, retries, async/await, quarantine, and clock domains are runtime vocabulary: they appear in semantics prose and runtime specs, never as surface syntax. Borderline entries carry their justification where declared (now/dt: logical time is contract-constrainable — rmdl §6.3). A future wire clause (deferred, §17.11) would clear the same bar — wire ABI is contract, not execution. Rejected for leaking: throws, a surface step, library time().


2. Lexical Conventions

The family has one lexer; these conventions bind family-wide. They are restated here because typl is the foundation document.

2.1 Encoding

Source files are encoded in UTF-8 (Unicode 15.0, RFC 3629). Non-ASCII characters are permitted only inside string literals and doc comments.

2.2 Whitespace, Line Endings, Separators

Whitespace (space, tab, CR, LF) is insignificant except as a token separator. A line break is an LF, a CRLF pair, or a lone CR (a CR that is not followed by an LF); a CRLF pair is one line break. A line comment and a doc comment line end at a line break of any of these three forms, a string literal and a regex literal cannot contain one, and diagnostic line numbers count each of them. Newline and comma are interchangeable separators in all block constructs; trailing comma is permitted. There are no semicolons — the ADR-0002 example package veh.common.types; is errata; the grammar has none (concept note §4.1 errata).

2.3 Identifiers

Three ASCII identifier conventions:

FormPatternUsed for
CamelCasestarts uppercase, no separatorstypes, structs, enums, enumsets, unions
camelCasestarts lowercase, no separatorsfields, tuple fields
SCREAMING_SNAKEuppercase, underscore separatorsenum values, enumset bits, constants

Identifiers start with a letter; digits permitted after the first character; underscore only in SCREAMING_SNAKE. Reserved words (§1.4) may not be used as identifiers.

2.4 Integer Literals

Follow ISO/IEC 9899:2018 (C17) §6.4.4.1: decimal, no leading zeros, unary minus for negatives (0, 42, -40).

2.5 Floating-Point Literals

IEEE 754-2019 double precision. Must include a decimal point (0.0, 3.14, -40.0). Scientific notation is not supported in v0.1. NaN and Inf are not permitted in range constraints.

2.6 String Literals

UTF-8 in double quotes. Escapes follow RFC 8259 §7: \", \\, \n, \r, \t, \uXXXX.

2.7 Regex Literals

Enclosed in forward slashes; syntax follows ECMA-262, restricted to the patterns that the Rust regex crate also compiles. Forward slashes inside the pattern are escaped \/.

/^[A-HJ-NPR-Z0-9]{17}$/

A pattern that is not ECMA-262 syntax is TYPL-106. A pattern that is ECMA-262 syntax but that the regex crate cannot compile is TYPL-220. The Rust backend emits a match constraint on a string-backed type as a regex::Regex::new call in the generated code, under the generated crate's validate-pattern feature (on by default), and a pattern that call refuses would fail at run time instead of at build time. A regex constant is emitted as text and is not compiled, but a match that names the constant is, so the constant is checked at its declaration (§6.2).

The ECMA-262 constructs that the regex crate refuses, and that a typl pattern therefore cannot use, include:

  • lookaround: (?=…), (?!…), (?<=…), (?<!…)
  • a backreference, numbered (\1) or named (\k<name>)
  • the control escape \cX, the NUL escape \0, and an identity escape of a letter the regex crate gives no meaning, such as \e, \Q or \k. An identity escape of a letter the crate does give a meaning, such as \a, \A, \z or \pL, compiles in both engines, with the different meanings the table below records
  • the empty class [], the negated empty class [^], and an unescaped [ inside a class, as in [[]
  • the backspace escape inside a class, [\b]
  • a class escape as a range bound, as in [\d-z] or [\w-.]
  • a quantifier with no lower bound, such as {,3}, and a { that opens no complete quantifier, as in { alone or a{
  • a pattern whose compiled form exceeds the regex crate's size limit. \w and \p{..} are large Unicode classes in that crate, so an ordinary pattern such as ^\w{1,256}$ exceeds the limit, while ^[A-Za-z0-9_]{1,256}$ does not. An artificial pattern such as (a{1000}){1000} exceeds it too.

This list is not complete. The rule is that the compiler compiles the pattern with both engines: a construct not listed here that the regex crate refuses is TYPL-220 too.

TYPL-220 guarantees only that the Rust output can compile the pattern. It does not guarantee that the checker, the Rust output and the output of another backend match the same strings. The checker tests a declared init value against a pattern (TYPL-109) with ECMA-262 semantics, and some patterns that both engines compile have a different meaning in each (driftsys/ridl#597):

In a patternECMA-262, no u flag (the checker)The Rust regex crate
\d, \wASCII digits, ASCII word charactersUnicode digits, Unicode word characters
\p{L}the text p{L}a Unicode letter
\A, \zthe letters A and zthe start and the end of the text
\athe letter athe bell character, U+0007
\<the character <a start-of-word boundary
[a&&b]one of a, &, bthe intersection of a and b, empty
[[a]]one of [, a, followed by ]the nested class [a]

2.8 Tokens recognised but not used by typl

Duration literals (500us, 10ms, 1s, 5min, 1h — the five suffixes ridl §2.1 tabulates) and the @ timing sigil are lexed family-wide but rejected by the .typl profile (TYPL-302).


3. Packages, Imports, and Visibility

The module system is ADR-0002's ns core, restated normatively for the typl profile. Four keywords: package, import, as, internal. Distribution (manifest, lockfile, cache) is tooling, not language — see ADR-0002.

3.1 Package Declaration

Every file begins with exactly one package declaration — dot-separated lowercase identifiers:

package veh.common

A package is a directory. All files in the directory declare the same package name, and the name mirrors the directory path relative to the manifest root. Mismatch is a hard error (TYPL-002). The package — not the file — is the unit of visibility, cycle checking, and codegen output.

A package cannot take a name the compiler provides (TYPL-010). Today that is ridl.std alone. Such a package can never be used: every package already imports all of ridl.std implicitly (§3.2) rather than through an import, so the declarations are unreachable under their own name. Where the generated artifact is named for the package, it is overwritten by the standard package's own as well. The reservation is the set of packages the compiler provides, not the ridl. prefix — ridl.stdlib is an ordinary package name.

3.2 Imports

Imports are qualified and named:

import veh.common.Speed
import marine.nav.Speed as MarineSpeed   // alias — collision release valve

Per ADR-0002 there are no wildcards, no relative imports, no re-exports. All three are hard errors.

Errata note. RIDL Language Reference v0.1 §2.2 showed import veh.common.* as a linter warning. ADR-0002, accepted later, bans wildcards outright. This document follows ADR-0002: wildcard import is a compile error (TYPL-003). The RIDL Language Reference should be amended to match.

Implicit imports — always available: all definitions in the same package, and all definitions in ridl.std (Appendix A).

Qualified references — any public definition may be referenced by fully qualified name without an import:

limit : veh.regulatory.SpeedLimit

A type reference that resolves through none of these — not the same package, not ridl.std, not an import, not a qualified name — or that names another package's internal declaration (§3.3) is a compile error (TYPL-011), reported on the written path in every position that takes a type reference.

3.3 Visibility

Two levels. Public is the default; internal is package-private:

struct WheelTick { ... }               // public — visible to importers
internal struct RawWheelFrame { ... }  // package-private

internal may prefix any typl definition (type, const, struct, enum, enumset, union). A public declaration must not expose an internal type in its fields, arms, bounds constants, or backing (TYPL-005) — the contract surface must be fully importable.

3.4 Cycles

Cycles within a package are permitted (mutually referencing declarations are normal). Cycles across packages are a hard error (TYPL-004). Note that recursive composite shapes are separately restricted — see §7.3.


4. Primitives

Five primitive types. Primitives are lowercase keywords — visually distinct from CamelCase named types. They serve as backing types in type definitions; direct use as field types is restricted (§15.3).

PrimitiveMeaningConstraint
booleanlogical true/falsenone
integerwhole number — width inferred from rangerecommended — profile may require
floatreal number — width inferred from range and steprecommended — profile may require
stringsequence of Unicode scalar values — default [0..256] if unspecifiedrecommended — profile may require
bytesopaque binary buffer — default [0..256] if unspecifiedrecommended — profile may require

4.1 Boolean

No constraint syntax. Serialized as uint8 on transport — 0 = false, 1 = true.

4.2 Integer

Range constraint [min..max] strongly recommended; active profiles may require it. Width is inferred once by the compiler from the declared range and passed to all backends:

RangeWire type
[0..255]uint8
[-128..127]int8
[0..65535]uint16
[-32768..32767]int16
[0..4294967295]uint32
[-2147483648..2147483647]int32
larger unsigned — up to [0..9223372036854775807]uint64
larger signed or no rangeint64

Integer domain. Every integer range must fit within [-2⁶³ .. 2⁶³−1] — the int64 domain. Bounds outside it are a compile error (TYPL-111). This closes the u64 hole: the language layer is always int64 (Kotlin Long, C++ int64_t), which cannot hold the top half of full-range uint64. A value that genuinely needs all 64 unsigned bits is modelled as bytes [8].

Language layer — always int64. Transport layer — narrowest wire type that safely contains the range. The resolved width is part of the contract: widening a range across a width boundary changes the wire type, which is a breaking change classified by ridl-diff. (An explicit width floor to pre-empt such flips — a wire clause — is deferred to v0.2, §17.11.)

4.3 Float

Range and step strongly recommended; active profiles may require them. step declares the value's quantization: valid values are origin + n·step, where n is an integer and origin is the declared lower bound, or zero when no lower bound is declared. This is a validity constraint, not a request to round an input. Integer values must lie exactly on the grid. Source literals and resolved constants use exact decimal grid membership. Floating-point constructors and payload validators use the representation rule below and preserve the supplied value rather than normalize it.

Floating-point grid validation uses the type's inferred wire precision. A constructor checks the value projected to that precision and preserves the supplied backing value. A payload validator checks the received representation at the same precision. This makes a constructed value's wire representation valid and keeps a decoded value valid under the same constructor check.

The runtime rule uses binary64 arithmetic over the projected value v. Prepare the phase p = origin modulo step exactly in decimal arithmetic, in [0, step), so shifting a grid by a large origin does not lose its phase. Represent phase and step as a binary64 high part and a residual low part; compensated arithmetic retains those residuals when reconstructing candidate grid points. Subnormal coefficients are scaled by an exact power of two before splitting and computation, then reconstructed values are scaled back.

The adjacent values at the wire precision define v's rounding cell. If the step is smaller than that cell's width, it contains a grid point and v is valid. Otherwise estimate q = (v - p) / step, with q = v / step - p / step as the subtraction-overflow fallback. Check the nearest integer index and its two neighbors, using compensated reconstruction and projecting each candidate to the wire precision. A candidate r admits v when abs(v - r) <= min(4 * epsilon * max(abs(v), abs(p), abs(step)), step / 4), where epsilon is machine epsilon at the wire precision. round selects the nearest integer, with ties away from zero. The quarter-step cap bounds the rounding allowance where the grid is resolvable.

A positive decimal step that underflows binary64 is dense in every finite rounding cell. For a step that overflows binary64, prepare its finitely representable grid points exactly before rounding them and use the same relative rounding allowance. NaN and infinity are never grid values. This is a bounded representation rule, rather than exact decimal arithmetic in the runtime or input normalization; a contract requiring exact decimal grid identity should use the scaled-integer form below.

Width inference is count-based, not step-based. Let N = (max − min) / step + 1 — the number of distinct representable values:

ConditionWire type
range + step declared, N ≤ 2²⁴, both bounds exactly representable in binary32float32
otherwise — no step, no range, N > 2²⁴, or bounds not representablefloat64

Errata note. RIDL Language Reference v0.1 §3.3 inferred float width from step alone (step >= 0.001 → float32). That rule is unsound: float [0.0..1000000.0 step 0.001] satisfies it but requires 10⁹ distinct values — far beyond binary32's 24-bit significand. The count-based rule replaces it; the RIDL Language Reference should be amended.

Quantized wire form — scaled integers. Because step anchors values at min + n·step, a quantized float is losslessly a scaled integer: raw = (value − min) / step, an unsigned integer whose width follows from N via the §4.2 table. Transports with a scaled-integer convention — CAN/DBC always, SOME/IP optionally per deployment — encode quantized floats this way (factor = step, offset = min). This is exactly AUTOSAR's LINEAR CompuMethod, so the ARXML mapping is direct, and it sidesteps binary-float step fuzziness (0.001 has no exact binary32 representation) on the transports that care most. proto3 and FlatBuffers keep native float/double. See Appendix D.

Language layer — always float64. Transport layer — as inferred above.

4.4 String

A string is a sequence of Unicode scalar values. string [N] bounds the count of scalar values — not graphemes, which are unbounded, and not bytes, which bytes counts. Default [0..256] when unspecified (warning).

The largest advised bound is 4,032 characters. A string of 4,032 characters is at most 16,128 bytes of UTF-8, which with the transport header and the encoding overhead fits in one transfer of 16,384 bytes (ADR-0030 decision 6). A bound above it draws TYPL-116 (warning), reported on the bound. The payload ceiling is 16,256 bytes, and the codecs do not yet enforce it (driftsys/ridl#996).

Every encoding carries a string as UTF-8. The in-memory representation is the target language's own — UTF-8 in Rust, UTF-16 in TypeScript, Kotlin and C# — and the codec converts at the buffer, as every proto and FlatBuffers runtime on those platforms already does. The byte capacity of string [N] is therefore 4·N, which is UTF-16's worst case as well, so UTF-8 costs nothing at the maximum.

Why not UTF-16 on the wire. proto3 string must be UTF-8 and FlatBuffers string is UTF-8, so a UTF-16 wire form means emitting bytes and [ushort] in the schemas. A consumer generated from those schemas would then see an opaque byte field where the contract says text, which breaks ADR-0018 decision 4's rule that interoperability is at the bytes, mediated by the emitted schema.

One consequence is not a projection rule and belongs to a runtime rather than to an encoding: a JavaScript string may hold a lone surrogate, which is not a scalar value and which TextEncoder silently replaces with U+FFFD, so a codec compiled to wasm cannot see the violation. The TypeScript runtime package checks well-formedness at its boundary, before handing the string to the codec, and rejects.

Two rules are an encoding's own rather than this reference's: how a string is stored in an in-place layout, and whether a terminator is guaranteed for a C reader. Both are settled by the podbuf projection record (ADR-0031 decision 5): the tail holds the bytes present followed by one NUL, and the fixed-capacity form is a rejected alternative of that record.

4.5 Bytes

Bound is in bytes; constraint syntax mirrors string but has no match. Default [0..256] when unspecified (warning).

The largest advised bound is 16,128 bytes, the byte size of a string at its advised bound (§4.4). A bound above it draws TYPL-116 (warning), reported on the bound.

4.6 Byte Order

Byte order is not a type property. A typl declaration never states one, and none of the ten inferred widths (§4.2, §4.3) carries an endianness.

Each encoding fixes it: proto3 and FlatBuffers each define their own, and the podbuf projection declares one. A transport that carries a per-signal choice — CAN and DBC's Intel/Motorola selection is the case that raises the question — takes that choice from the deployment, which is rsdl's, and never from the type. The rsdl surface for stating it lands with the rsdl rewrite at v0.2 (§17.9).


5. Type Definitions

The type keyword defines a named scalar — a constrained primitive or a physical unit type. Named types carry domain meaning and are the primary building blocks for all fields.

5.1 Physical Unit Types

A unit type associates a scalar with a unit from the Unified Code for Units of Measure (UCUM) — the unit standard used by OpenTelemetry, HL7 FHIR, and ISO 11240:

type Speed       : km/h  [0.0..250.0 step 0.5]
type Temperature : Cel   [-40.0..125.0 step 0.1]
type Torque      : N.m   [0.0..500.0 step 0.1]
type RPM         : /min  [0.0..8000.0 step 10.0]
type Pressure    : bar   [0.0..10.0 step 0.01]
type Voltage     : V     [0.0..48.0 step 0.1]
type Ratio       : %     [0.0..100.0 step 0.1]

In v0.1 the underlying primitive is float, whatever the literal type of the range (§17.12 records the v0.2 rule, under which integer literals give an integer backing). UCUM expressions are case-sensitive. Because a unit type carries unit, range, and step, downstream layers inherit dimensional checking and range-driven saturation — rmdl computes with Speed, not bare real (concept note §6).

Common automotive UCUM units: km/h, Cel, N.m, /min, m/s2, bar, V, A, W, %.

5.2 Constrained Primitive Types

type Counter : integer [0..65535]
type Gain    : float   [0.0..1.0 step 0.01]
type Vin     : string  [17 match VIN_PATTERN]
type Frame   : bytes   [8]

5.3 String Constraint Syntax

SyntaxMeaning
string [N]exactly N characters
string [min..max]min to max characters
string [N match PATTERN]exactly N, validated
string [min..max match PATTERN]range with validation

match references a named regex constant or an inline regex literal.

5.4 Bytes Constraint Syntax

SyntaxMeaning
bytes [N]exactly N bytes
bytes [min..max]min to max bytes

No match — bytes are opaque.

5.5 Range Bounds

Ranges are closed (inclusive) on both ends. Either bound may be omitted ([0..], [..255]), in which case the missing bound defaults to the widest value the inferred width allows.

NaN is outside every declared numeric range, including a range with an omitted bound. NaN is also outside every step grid (§4.3); a grid admits only finite values. A numeric range's omitted bound is the widest finite backing value, so a range also excludes infinities. An unconstrained float retains its backing representation's NaN and infinity values. Declaring a range or step therefore changes validity, without globally restricting the float primitive to finite values. These rules apply to named types and inline constraints. The Rust backend enforces them in checked constructors for named scalars and in payload verification for named and inline constraints. Public primitive fields do not validate assignment. The reason is domain membership: NaN has no ordered position in a closed interval and no integer grid index.

Exclusive bounds are not supported, and are not planned. An integer's exclusive bound is written as the closed bound one value inside it. A float's exclusive bound has no definable neighbour: §4.3 derives the width from the range, so "the next representable value" would depend on a width that depends on the bound itself. The forcing case that would reopen this: a published contract whose semantics genuinely require an open interval on a float.

5.6 Width Is Inferred, Never Written

typl v0.1 has no surface syntax for wire width. A range is the only width control an author needs: integer [0..255] is a uint8, integer [0..65535] is a uint16, and the compiler derives the narrowest safe width once (§4.2, §4.3) and passes it to every backend. The ten concrete width names (uint8 … float64) are not keywords, not primitives, and not writable anywhere in a .typl source — writing one is a parse error (there is no production for it).

Range-driven inference has one known hazard: widening [0..250] to [0..300] silently flips the wire type uint8 → uint16 — a wire ABI break from a semantically innocent edit (hardest on FlatBuffers and CAN, invisible on proto3 varint). v0.1 handles this with the ridl-diff gate alone: it classifies every resolved-width change as breaking, so the flip is caught in CI. An explicit width floor that would pre-empt the flip at the source — a wire clause pinning headroom above the boundary — is deferred to v0.2; see §17.11.

A resolved width is part of the contract on every encoding, and part of the memory layout on some. On an encoding with a fixed inline layout there is no vtable to absorb a change of size, so a width change moves every later field. The ridl-diff classification above covers both cases, because it is the resolved width that it compares. A backend that emits a fixed inline layout must therefore treat a width change as a layout break and must not rely on a reader tolerating it.

5.7 Type Identity — Nominal

Every named type is nominally distinct — from its backing primitive and from every other named type, even one with identical constraints. Speed and Torque are both float-backed; they are not assignable to each other, and neither accepts a bare float. A literal is accepted where a named type is expected when it satisfies the type's constraints (this is how const MAX_SPEED : Speed = 250.0 works); a value of another named type never is. There is no implicit conversion anywhere in the family — explicit conversion is expr/rmdl territory, where unit dimensions decide convertibility (§17.5).

Precedent: Ada derived types, Rust newtypes, Haskell newtype. Rationale: nominal identity is what makes unit safety real — structural typing would let a Torque flow into a Speed port because both are "float in a range", which is precisely the class of error this language exists to prevent. Codegen realises nominal types as distinct wrapper types where the target allows (Rust newtype, Kotlin value class) and as documented aliases where it does not (proto3).

A language backend's wrapper type also carries the type's constraints: its value is built through a constructor that checks the range, step and length bounds of §5.1 to §5.5 and reports a violation instead of building the value, beside a constructor that does not check, for a value the payload validator has already checked. A backend may make the pattern check optional. A wire backend emits neither, because a schema cannot refuse a value when it is built. ADR-0013 decision 8 is the rule, and records which backends emit the checking constructor today.

5.8 Init Values

Every type has an init value — the value a consumer holds before anything real arrives (the interaction layer's signal channels start from it; AUTOSAR initValue is the precedent). It is either declared or derived:

Declared — a bare = value suffix, valid on type definitions as well as struct fields. There is no keyword: the value simply follows the constraint.

type Speed : km/h [0.0..250.0 step 0.5] = 0.0

The bare = value form is the family's single init syntax at the vocabulary and interaction layers (typl types, typl struct fields, ridl signal overrides). It carries no keyword — not default (retired) and not init (which belongs to rmdl alone, where init x = value seeds a memory recurrence and the keyword is needed to disambiguate it from the equation).

Derived — when no = value is declared:

TypeDerived init
booleanfalse
numeric / unit type0 (or 0.0) if admitted by both range and step, else the lower bound when declared
string / bytesempty if the bounds admit length 0; not derivable otherwise
type with match patternnot derivable (a pattern-valid value cannot be synthesised) — declare a = value
enumthe value 0 if declared, else the lowest declared value
enumsetempty set
structeach field's init, recursively; optional fields absent
unionfirst arm's init
tupleeach field's init
arraymin-bound count of element inits (empty when min = 0)
mapempty when min = 0; one key/value init when min = 1 and both are derivable; otherwise not derivable

For a numeric range with no lower bound whose upper bound is negative, derive the upper bound when no step is declared. With a step, derive the greatest grid point at or below that upper bound, using the zero origin and exact decimal arithmetic. A derived floating-point init must fit the finite backing domain; when no finite grid point is available, the init is not derivable.

A map with a minimum count above one has no derived init: repeating a key's init would violate key uniqueness, and the toolchain does not synthesize distinct keys. Optional map fields without an override remain absent.

A declared init value must have the backing's kind and satisfy the type's constraints, including step (TYPL-109). Floating-point init values and typed floating-point constants must lie within the finite binary64 backing domain. Integer and enum init values may use an exactly integral numeric spelling such as 1.0; an enum value must name a declared discriminant. Constant references are checked after resolution; an unresolved reference is an invalid init. A string literal initializes string or its UTF-8 bytes initialize bytes, with the corresponding character or byte length check. A boolean literal initializes only boolean. The init syntax has no composite or enum-set literal, so a literal or constant override on a struct, union, tuple, array, map or enum set is rejected rather than stored as an untyped scalar. Derived composite inits remain recursive as listed above. A type whose init is not derivable is simply marked so in the IR (TYPL-115, info) — it becomes an error only where a consumer requires an init (e.g. a ridl signal payload, ridl §4.4) and none is declared.


6. Constants

const defines a named compile-time constant — package-scoped, importable, and reusable in range constraints, match patterns, and init (= value) declarations. Higher profiles also use constants in require/ensure expressions.

6.1 Value Constants

package veh.common

const MAX_SPEED      : Speed   = 250.0
const SPEED_LIMIT_EU : Speed   = 130.0
const MAX_GEAR       : integer = 6
const IDLE_RPM       : RPM     = 800.0

SCREAMING_SNAKE naming. The declared value must satisfy the constant's type constraints (TYPL-108). Reuse:

type Speed : km/h [0.0..MAX_SPEED step 0.5]

struct GearState {
  gear : integer [0..MAX_GEAR]
}

6.2 Regex Constants

A const may hold a regex literal, reusable in match constraints. The pattern follows §2.7, and TYPL-106 or TYPL-220 is reported at the constant's declaration, not at each match that names it. The Rust backend emits the constant itself as text, not as a compiled pattern:

const VIN_PATTERN   = /^[A-HJ-NPR-Z0-9]{17}$/
const ASCII_PATTERN = /^[\x20-\x7E]+$/
type Vin   : string [17 match VIN_PATTERN]
type Email : string [1..254 match /^[^@]+@[^@]+\.[^@]+$/]

7. Structs

A struct is a named composite with a fixed, closed set of named fields. There are no additional/unknown properties — a struct is closed by definition (contrast JSON Schema's open-world default, Appendix G). A field name is unique within its struct (TYPL-215): a name identifies one field, so a struct cannot declare two.

struct DriverProfile {
  name     : Name
  speed    : Speed
  override : Speed?     // optional — may be absent
}

7.1 Optionality

The ? suffix marks a field as optional — may be absent, which is distinct from "present with a null value"; typl has no null. Maps to optional in proto3, Option<T> in Rust, T? in Kotlin.

7.2 Field Init Values

A field may carry a bare = value suffix — its init value, the same syntax a type uses (§5.8). There is no attribute block and no keyword; typl carries no other field attribute at the vocabulary layer.

struct RetryPolicy {
  attempts : integer [0..10] = 3
  backoff  : Duration        = 100.0
}

The init value must satisfy the field's type (TYPL-109). require/ensure are expr-core attributes owned by higher profiles and are rejected in .typl (TYPL-303).

7.3 No Recursive Shapes

A struct (or union) must not reference itself, directly or through any chain of composite references. Recursion makes the wire size unbounded, which contradicts the bounded-size guarantee every typl composite otherwise carries. Recursive composite reference is a compile error (TYPL-206). A genuine tree- or graph-shaped payload is modelled with explicit indices into a bounded array.

This is not provisional. The bounded-size guarantee is what the rest of the language rests on: §12 requires an explicit bound on every variable-size collection, §4.3 infers float width from a count of representable values, and an encoding with a fixed inline layout cannot exist at all for a shape whose size is not known at compile time. The forcing case that would reopen it: a published contract whose payload is genuinely tree-shaped and whose depth cannot be bounded at the source.

7.4 Field Identity and Evolution

Tag-based transports (proto3 field numbers, FlatBuffers field ids) need a stable numeric identity per field. typl assigns it implicitly, by declaration order (1-based), for struct fields and union arms alike. There is no explicit = N tag syntax.

That makes evolution discipline part of the language:

  • Append-only. New fields are added at the end of the struct or union. Inserting or reordering fields shifts the ordinals of everything after them — a wire break, rejected by ridl-diff.
  • Delete by tombstone. A removed field keeps its ordinal slot occupied with reserved:
struct DriverProfile {
  name     : Name
  reserved legacyChecksum      // was ordinal 2 — slot retired, never reused
  speed    : Speed
}

Re-declaring a field under a reserved name is a compile error (TYPL-210).

reserved in an enum body. Both forms are legal there, and they guarantee different things. reserved 3 retires the wire value 3: it is never reused with a new meaning. reserved Retired retires the name Retired: it is never re-declared, and it says nothing about any wire value — a value is retired only by the integer form, because the value a retired name once carried is not recoverable from the source that no longer declares it. Re-declaring either draws TYPL-210. A backend projects a name tombstone where its target has a name reservation of its own — proto3's reserved "NAME"; — and drops it where its target has none, as FlatBuffers does.

  • ridl-diff is the enforcement point. Ordinals are fully derivable from source — no sidecar state, no lockfile-assigned numbers. The CI plane (ridl-diff against the previous IR snapshot) rejects reorder, insertion, un-tombstoned deletion, and an append that the backward-compatibility decoder rule below cannot read, in published packages.

  • Decoder rule for forward compatibility. The flip side of append-only: a decoder built against version n of a struct, receiving a payload from a version n+k encoder, must ignore unknown trailing fields (and should preserve them when acting as a proxy — the proto3 unknown-field lesson). This is native on proto3/FlatBuffers; on positional transports it requires length-prefixed framing (SOME/IP TLV) or is simply unavailable (CAN) — where unavailable, mixed-version communication is a deployment error rsdl must surface (§17.9).

  • Decoder rule for backward compatibility. The other direction: a decoder built against version n+k of a struct, receiving a payload from a version n encoder, finds every field appended since n absent. An absent optional field reads as absent. An absent non-optional field reads as the transport's default, 0, only when the field is a scalar, an enum or an enum set whose type allows the value 0: a range and step that hold 0 (§4.3), an enum that declares a member with the value 0, or any enum set (0 is the empty set). Any other absent non-optional field — a string, bytes, struct, union, tuple, array or map field, or a scalar or enum whose type excludes 0 — has no value to read, and the decoder refuses the payload rather than invent one. So ridl-diff reports appending a non-optional field as breaking unless its type allows 0; the author declares the new field optional (field : T?) instead (driftsys/ridl#598). A type the diff cannot resolve is reported as breaking; ridl diff resolves types against the new snapshot and the built-in ridl.std.

Rationale. Explicit proto-style tags were considered and rejected: they impose a per-field numbering ritual on every struct — including the majority bound to positional transports (CAN, SOME/IP) where the numbers mean nothing — and gaps/vanity numbering must then be linted. Implicit ordinals keep the surface small and the audit trail honest: the wire layout is readable from the .typl file alone. The discipline this demands — append-only with tombstones — is the discipline a contract language wants anyway. Trade-off on record: reordering is legal syntax, and only ridl-diff makes it an error; publishing without the CI gate is unprotected. Judged acceptable because ridlc --frozen + ridl-diff are already mandatory in the ecosystem's CI plane.


8. Enums

An enum is a named set of discrete integer-backed values, for single-value selection:

enum GearPosition {
  PARK    = 0
  DRIVE   = 1
  REVERSE = 2
  NEUTRAL = 3
}
  • Backing type is integer, implicit; all values explicitly assigned; values unique (TYPL-203)
  • A value name is unique within its enum (TYPL-216): a name identifies one value, so an enum cannot declare two
  • First value conventionally = 0 for proto3 compatibility
  • String-backed enums are not supported in v0.1; planned for v0.2 (§17.1)

Identity is the value, not the position. An enum value's wire identity is the explicit number it declares, and nothing about it depends on where the value sits in the body. Reordering the members of an enum body therefore changes nothing on any wire; ridl diff reports it as enum_reordered, compatible. This is the opposite of a struct field or a union arm, whose identity is its declaration order (§7.4), and it is why an enum value may be inserted in the middle of a body while a struct field may not.

As built. ridl diff compares enum values and enumset bits by the number each declares. A member whose number changed is reported as member_reordered with its old and new number, breaking. A body whose difference is only order — its values or bits reordered with no number changed, or an enum's reserved list reordered with no entry changed — is reported as enum_reordered, compatible: nothing moves on the wire. The catalog hash covers only the declarations an interface reaches; when an interface reaches the enum or enumset, the order of its values or bits, and of an enum's reserved list, is part of the hash, so the hash changes then.


9. Enum Sets

An enumset is a named bitfield — multiple flags active simultaneously. Backing width inferred from the highest bit position.

An enumset bit's identity is the bit position it declares, not its position in the body, so reordering the members of an enumset body changes nothing on any wire, and ridl diff reports it as enum_reordered, compatible — the same rule as §8's, for the same reason.

9.1 Standalone Form

enumset WarningFlags {
  LOW_FUEL     = 0    // bit 0
  CHECK_ENGINE = 1    // bit 1
  DOOR_OPEN    = 2    // bit 2
  SEATBELT     = 3    // bit 3
}
  • Bit positions unique (TYPL-207)
  • A bit name is unique within its enumset (TYPL-218): a name identifies one bit, so an enumset cannot declare two
  • A bit position is a number with an integer value (TYPL-219): a string, a boolean, a fractional number, a regex, or a constant reference is refused, 1.0 is accepted as bit 1, and a position outside bits 0..63 is TYPL-111

9.2 Derived Form

Derives bit positions from an existing enum — preferred when both single-value and set use are needed:

enum Warning { LOW_FUEL = 0, CHECK_ENGINE = 1, DOOR_OPEN = 2, SEATBELT = 3 }

enumset WarningFlags : Warning

9.3 Width Inference

Highest bitWire type
0..7uint8
8..15uint16
16..31uint32
32..63uint64

Language layer — always int64. Transport layer — narrowest inferred.


10. Unions

A union is a discriminated (tagged) union — exactly one arm active at any time:

union SensorResult {
  ok  : SensorReading
  err : SensorFault
}
  • Arms reference named types only — primitives not permitted directly (TYPL-204)
  • An arm name is unique within its union (TYPL-217): a name identifies one arm, so a union cannot declare two
  • Untagged unions and intersections (JSON Schema anyOf/allOf) are deliberately not supported — see Appendix G
  • Maps to oneof in proto3, union in FlatBuffers, sealed class in Kotlin, Rust enum

10.1 Error Types

The error modifier marks a named composite as failure vocabulary:

error enum DiagError {
  FILTER_INVALID = 0
  STORAGE_BUSY   = 1
  ACCESS_DENIED  = 2
}

error struct CalFault {          // data-carrying failure
  code   : integer [0..255]
  detail : Message
}

error union ServiceFault {       // composes error types — all arms error-typed
  diag : DiagError
  cal  : CalFault
}
  • error is valid on enum, struct, and union only (TYPL-212); an error union's arms must all be error-typed (TYPL-214)
  • An error type is otherwise an ordinary type — package-scoped, importable, nominally distinct, usable wherever data is legal (a fault-log struct may contain one; a signal may publish one). The marker adds exactly one bit of meaning: this shape describes a failure
  • Codegen realises error types with the target's error material where it exists (Rust std::error::Error impl, Kotlin sealed failure hierarchies) and as plain data elsewhere

10.2 Result Unions

A union mixing error and non-error arms is a result union and must have exactly two arms — one success arm (any non-error type) and one error arm (an error type). Anything else that mixes the two kinds is malformed (TYPL-213). Several failure kinds compose into one error union before entering the result union.

union CalOutcome {
  ok  : CalReport
  err : CalFault
}

A result union is the family's Result<T, E> — achieved without generics, consistent with the no-parametric-types decision. In pure typl a result union is simply a union; the interaction layer (ridl §10.1) recognises it at interaction positions and maps the error arm onto transport-native error channels. This is the family's entire functional-error mechanism: failure is data, declared once in the vocabulary — there is no throws, no exception, no second channel anywhere in the family.


11. Tuples

A tuple is an anonymous composite of named fields used inline — as struct fields at the typl layer; higher profiles additionally use tuples as query returns (ridl §7.1). A tuple is not an interaction parameter type: a parameter is a named type or a stream (ridl §6.1, §7.1).

struct SensorBounds {
  range : (min: Speed, max: Speed)
}
  • Fields are always named — positional access is not permitted
  • A field name is unique within its tuple (TYPL-215), as in a struct (§7)
  • The empty tuple () is the unit type (its interaction role is defined by ridl)
  • The same tuple shape used in multiple places draws a linter warning: extract a named struct (TYPL-205)

12. Collections

Collections are finite, bounded aggregates. All variable-size collections require explicit bounds — no defaults, no exceptions (TYPL-201/202).

12.1 Array

readings : [Speed; 8]          // fixed — exactly 8
faults   : [FaultCode; 0..32]  // bounded — 0 to 32

12.2 Map

Keys must be a named string type or primitive. Each key occurs at most once in a map; a payload containing duplicate keys is invalid. Equality is value equality in the key's declared type, including floating-point equality (+0.0 and -0.0 compare equal; NaN does not compare equal to itself). Validation must reject duplicates rather than choose which entry to retain:

metadata : [Label : Name; 0..32]
sensors  : [Label : Speed; 1..8]

A bare string or bytes key takes §4.4–§4.5's [0..256] length default, with TYPL-103, the same as every other position — §15.3 keeps a bare string/bytes out of a field position, but a map key is not one, so the default applies there instead. A key therefore always carries a length bound, which is what lets a projection state a finite size for the map.

A map has no declared entry order, and typl defines no canonical payload encoding. A map is the only typl construct whose ordering is free: an array is ordered, a struct field and a union arm are identified by ordinal (§7.4), and an enum value and an enumset bit carry explicit numbers (§8, §9). So a bit-reproducible byte sequence — what a signed payload, a content hash or a replay plane needs — is a property of an encoding and of the frame that carries it, not of the type. The rule that fixes it, including the entry order a map serialises in, belongs to the frame specification and to each projection record (§17.10).

12.3 Bound Rules

ConstructSyntaxBound
Exact-length array[T; N]exactly N — mandatory
Bounded array[T; min..max]min to max — mandatory
Bounded map[K:V; min..max]min to max entries — mandatory

The stream container <T> is the deliberate exception to boundedness and lives in the ridl profile (§1.3).


13. Comments

Discarded by the compiler.

// line comment
/* block comment — nesting not supported */

14. Doc Comments

A doc comment documents the next named declaration or member, called its carrier. It is processed by documentation generators and editors and has no semantic effect: the catalog hash clears every doc field, and ridl diff classifies a change to one as DocOnly. No blank line may separate a doc comment from its carrier (TYPL-404). The decisions behind this section are ADR-0026.

14.1 Syntax

/// Current calibrated wheel radius
type WheelRadius : m [0.20..0.45 step 0.001]

/// lines are the house style. A block /** ... */ has the same meaning; a leading * on each of its lines is decoration and is stripped. A project can require /// with the doc-comment-style lint (TYPL-410, allow by default).

A doc is CommonMark, and a bracketed name in it is a link to a declaration or a member (§14.5). The carriers are listed in §14.4.

Examples are written under a # Examples heading with a fenced code block. This is a convention: the compiler does not read it.

14.2 Tags

A tag is @word at the start of a line of the doc, after the comment markers and leading whitespace. An @ anywhere else is prose. A tag line is not part of the doc text. Each tag may appear more than once, and every value is kept.

TagValueValid on
@seeone qualified name, with an optional member; a linkall
@sincea version, MAJOR.MINOR or MAJOR.MINOR.PATCHall
@labelscomma-separated classification labels (§14.3)all
@deprecated"reason string"all

Any other tag draws TYPL-408. A @see or @since with a missing or malformed value draws TYPL-409, and a @see target that does not resolve draws TYPL-401. A @deprecated with no reason draws TYPL-405. Every tag is accepted on every carrier, and @deprecated and @labels are stored in the IR on the carriers that have the field — a declaration, a struct field, an interface, an interaction and a service. On an enum value, an enumset bit, a union arm, a parameter and an rsdl carrier the two tags are read and dropped, with no diagnostic. Both stay doc tags: ADR-0028 decision 4 declines the general form's plan to make deprecated and labels attribute keys.

14.3 Labels

@labels carries free-form SCREAMING_SNAKE classification identifiers (optionally with a parenthesised suffix, e.g. MY_LABEL(D)). The core language defines no vocabulary — an external profile validates labels and enforces combinations. The compiler passes labels through to generated metadata unchanged.

14.4 Carriers

CarrierA doc is required (TYPL-406)
type, const, struct, enum, enumset, union, interface, servicewhen not internal
struct field, enum value, enumset bit, union arm, interactionwhen the declaration is not internal
parameter of a command or querynever
reserved entrynever
rsdl system, component, distribution, deployment, machinealways
rsdl body line (offers, requires, a bare member reference)never

A package needs no doc. A doc made only of tags (§14.2) counts as missing. A parameter's type is always a named type, which carries its own doc. A doc comment in any other position — before package, an import, a return type or an attribute block, an arm of an inline T | E return, or at the end of a file or a body — is misplaced (TYPL-407).

Three bracket forms are doc links:

  • [Name] and [pkg.Name];
  • [`Name`], the code-span form;
  • [text][Name], with an explicit label.

The bracket content is a link only when it is a qualified identifier with at most one member suffix: [0..250] and [see below] are prose. A name inside a code span or a fenced code block is not a link, and a [Name] that has a CommonMark link reference definition in the same doc is an ordinary Markdown link.

A link resolves in the scope of the file that holds it, with the rules of a type reference (§3): a bare name is looked up locally, then among the imported names, then in ridl.std. A qualified pkg.Name names any package the current package can depend on (ADR-0002 §5) without an import; a remote package of [imports] is reachable where an import of it resolves — the package's own [imports] or, for a workspace member, the workspace [imports] pin its unit. One more segment names a member of the declaration: a field, enum value, enumset bit, union arm or interaction ([Gear.PARK], [CruiseControl.setLever]). When a name could be a package path or a declaration followed by a member, the link resolves as a type reference resolves the same name. A declaration that is internal in another package cannot be linked to. A link that does not resolve draws TYPL-401; each link that resolves is stored in the IR with its canonical target.


15. Conventions

15.1 Naming

ConstructConventionExample
type, struct, enum, enumset, unionCamelCaseVehicleSpeed, WarningFlags
Fields, tuple fieldscamelCasecurrentSpeed
Enum values, enumset bits, constantsSCREAMING_SNAKEPARK, MAX_SPEED
Packageslowercase.dotveh.common

15.2 Separators

Newline and comma interchangeable in all block constructs; trailing comma permitted; no semicolons.

15.3 Type Usage

  • string and bytes must not be used directly as field types — always define a named type (the ridl profile grants streams an element-type exception; there is no exception inside typl)
  • All variable-size types declare explicit bounds
  • union arms reference named types only
  • Prefer named types over inline primitives for all domain concepts

15.4 Imports

Explicit single-type imports only (wildcards are an error, §3.2). Aliases only on genuine collision — linters flag gratuitous aliases.


16. Diagnostics

typl adopts stable diagnostic codes — a practice inherited from the markspec typl catalogue (Appendix F). Codes are namespaced TYPL- and grouped by hundreds: 0xx module, 1xx scalars & constants, 2xx composites, 3xx profile boundary, 4xx documentation. Codes are never renumbered; retired codes are marked deprecated and never reused.

The tables below are the TYPL- codes only. A .typl file also draws the two namespaces no profile owns — FORM- (surface syntax: lexical, parse, and the attribute-block rules) and MANI- (the manifest). Both are tabulated once in the family overview §7 and are not restated here.

A .typl file also draws one RIDL- code, RIDL-149: two names in one scope that collide after a pinned name transform (ADR-0016). Three of its scopes are typl declarations — the fields of one struct, the arms of one union, and the values of one enum — so LEVEL_10 beside LEVEL10 in one enum is refused, as both become Level10. Its row, with the transform each scope is checked under, is in ridl §16.4.

16.1 Module (TYPL-0xx)

CodeRuleSeverity
TYPL-001more than one package declaration in a fileerror
TYPL-002package name does not mirror the directory patherror
TYPL-003wildcard, relative, or re-exporting importerror
TYPL-004circular package importserror
TYPL-005a public declaration exposes an internal typeerror
TYPL-006conflicting imports without an aliaserror
TYPL-007unused importwarning
TYPL-008import alias without an actual collisionwarning
TYPL-009duplicate definition of the same name in a packageerror
TYPL-010package name is reserved for a package the compiler provideserror
TYPL-011type reference names no visible declaration (§3.2, §3.3)error

16.2 Scalars and Constants (TYPL-1xx)

CodeRuleSeverity
TYPL-101integer without a range constraintwarning; error if active profile requires
TYPL-102float without both a range and a stepwarning; error if active profile requires
TYPL-103string/bytes without explicit bounds — default [0..256] appliedwarning; error if active profile requires
TYPL-104range min > maxerror
TYPL-105step type mismatch, non-positive, or larger than the rangeerror
TYPL-106invalid regex syntax in match or a regex const — a valid pattern the Rust regex crate refuses is TYPL-220error
TYPL-107regex contradicts declared character boundwarning
TYPL-108const value violates its declared type constraintserror
TYPL-109init = value incompatible with the type or field constraintserror
TYPL-110unknown or malformed UCUM unit expressionerror
TYPL-111integer range bound outside the int64 domain ([-2⁶³..2⁶³−1])error
TYPL-112a concrete wire-width name (uint8 … float64) written in source — width is inferred, not written (§5.6)error
TYPL-115type has no derivable init value and no declared = value (§5.8)info — escalated by consumers that require an init
TYPL-116string/bytes bound above the advised transfer limit — a string bound above 4,032 characters or a bytes bound above 16,128 bytes (§4.4, §4.5), reported on the bound; the payload ceiling is 16,256 bytes, which the codecs do not yet enforce (driftsys/ridl#996)warning

16.3 Composites (TYPL-2xx)

CodeRuleSeverity
TYPL-201array without explicit boundserror
TYPL-202map without explicit boundserror
TYPL-203enum values not unique or not explicitly assignederror
TYPL-204union arm with a primitive typeerror
TYPL-205same tuple shape used in multiple placeswarning
TYPL-206recursive composite reference, direct or transitiveerror
TYPL-207enumset bit positions not uniqueerror
TYPL-208string/bytes used directly as a field typeerror
TYPL-209map key is not a named string type or a primitiveerror
TYPL-210field, arm, or enum value re-declared under a reserved entry (a name or a value)error
TYPL-211duplicate reserved entry — a dangling entry (a name or value never previously used) falls under this rule too, but its check needs the previous IR snapshot and is not builtwarning
TYPL-212error modifier on a declaration other than enum, struct, unionerror
TYPL-213union mixes error and non-error arms without the result-union shape (exactly one success + one error arm)error
TYPL-214error union contains a non-error-typed armerror
TYPL-215field name declared twice in one struct or one tupleerror
TYPL-216enum value name declared twice in one enumerror
TYPL-217union arm name declared twice in one unionerror
TYPL-218enumset bit name declared twice in one enumseterror
TYPL-219enumset bit position is not a number with an integer valueerror
TYPL-220regex pattern the Rust regex crate cannot compile (§2.7) — numbered in this range, but not a compositeerror
TYPL-222one field name used with different unitsinfo
TYPL-224duplicate declaration shapeinfo

16.4 Profile Boundary (TYPL-3xx)

Emitted when a .typl file (or a package declared profile = "typl" in ridl.toml) contains constructs of a higher layer. TYPL-301 is also emitted in a .ridl file, as its row says:

CodeRuleSeverity
TYPL-301stream type <T> outside interaction position — in a typl context, and in a ridl file anywhere but a parameter or a query return, such as a struct field, a collection, a type definition or a union arm, except a position diagnosed separately: a signal or event payload is RIDL-201, a fixed payload is FORM-102 and a command return is RIDL-104 (ridl §12.3)error
TYPL-302timing annotation or duration literal in a typl contexterror
TYPL-303require/ensure attribute in a typl contexterror
TYPL-304interaction declaration in a typl context — one of the ridl interaction words at declaration start; an rmdl or rsdl word there is FORM-102error

16.5 Documentation (TYPL-4xx)

CodeRuleSeverity
TYPL-401doc link or @see target that does not resolve — the name is unknown, is internal in another package, or names no member of the declarationwarning
TYPL-402@labels identifier not recognised by active profileinfo
TYPL-403@labels combination invalid per active profileerror
TYPL-404blank line between a doc comment and its carrier — a declaration or a memberwarning
TYPL-405@deprecated doc tag without a reason stringwarning
TYPL-406item without a doc comment — a declaration that is not internal, a member of one, or an rsdl declaration; a doc made only of tags is missingwarning
TYPL-407doc comment in a position that is not a carrier — before package, an import, a return type or an attribute block, or at the end of a file or a bodywarning
TYPL-408doc tag other than @see, @since, @deprecated and @labelswarning
TYPL-409@see or @since with a missing or malformed valuewarning
TYPL-410doc comment written as /** */ — allow by defaultwarning

17. Open Questions

Every question this section has carried is disposed of. A question is either resolved — its rule has moved into the section of this reference that governs it, and it no longer appears below — or deferred to a named version, in which case it appears below with that version.

The numbers are stable. Other documents cite these questions by number, so a resolved question's number is retired rather than reused, and the ledger records where its rule now lives. A number with no entry after the ledger is resolved, except §17.10, which is resolved and keeps its entry because other documents cite that section.

17.0 Disposition Ledger

#QuestionDisposition
17.1String-backed enumsdeferred to v0.2
17.2Exclusive range boundsresolved — not supported and not planned; the rule and its reason are §5.5
17.3uniqueItems for arraysdeferred to v0.2, as an array constraint; the set container is rejected
17.4Recursion policyresolved — the hard error stands; the rule and its reason are §7.3
17.5Unit conversion semanticsdeferred to the rmdl era
17.6Scientific notation in float literalsdeferred to v0.2
17.7Value-rule expressionsdeferred to the family expr core
17.8Standards-fixed invalid sentinel valuesdeferred to v0.2; the rest of the original question is resolved
17.9Byte orderresolved for typl — byte order is not a type property, §4.6; the deployment surface is rsdl's, at rsdl v0.2
17.10Canonical (deterministic) encodingresolved — not a type-layer property, §12.2; the frame specification §4 and each projection record own the rest
17.11Explicit wire-width floorsplit: the layout consequence is resolved in §5.6; the wire clause is deferred to v0.2
17.12Integer-backed unit typesdeferred to v0.2
17.13What a string is made of, and how it is encodedresolved — the rule is §4 and §4.4
17.14Value-aware enum and enum-set reorder comparisonresolved — the rule is §8 and §9
17.15A reorder in the same edit as an addition or a removaldeferred to v0.2, sequenced behind driftsys/ridl#302

One question outside this section was disposed of in the same pass: whether a name-based reserved is legal in an enum body, and what it guarantees (driftsys/ridl#245). It is legal and it retires the name; the rule is §7.4.

17.1 String-Backed Enums — deferred to v0.2

JSON Schema, TypeScript, and OpenAPI make string enums the common case; typl v0.1 supports only integer-backed enums (wire efficiency, DBC/CAN mapping). Candidate design: enum Region : string { EU = "eu", US = "us" } — integer wire form with a declared string rendering.

Deferred to v0.2, with the candidate design above as the starting point. The wire form stays integer, so nothing in §4.2's width table, in ridl-diff, or in any encoding changes; what is added is a rendering per value, carried to the targets that have somewhere to put it. It is the one gap in Appendix G's matrix with named external consumers.

17.3 uniqueItems for Arrays — deferred to v0.2

Not expressible today. The set collection once suggested here ({T; min..max}) is rejected: no target this family emits has a set type — proto3, FlatBuffers and podbuf each carry a vector plus a rule — so a set container would add a surface with no projection, and every backend would flatten it back into an array.

Uniqueness is instead a constraint on an array, alongside the range, step, length bound and match: enforced by the generated validator and carried as documentation on the wire targets. Deferred to v0.2, where the generated validator is the enforcement point.

17.5 Unit Conversion Semantics — deferred to the rmdl era

UCUM units are currently labels with dimensional identity. Whether typl-core defines convertibility (km/h ↔ m/s), or leaves conversion to codegen, is open.

Deferred to the rmdl era — the question is claimed by rmdl §12 open question 8, where cross-unit arithmetic (Speed * Duration : Length) makes it concrete, and RMDL-106's explicit-conversion rule is the stopgap until then. typl's own commitment is unchanged and is not waiting on that answer: a unit is a label with dimensional identity, and nominal identity (§5.7) forbids implicit conversion in every profile.

17.6 Scientific Notation in Float Literals — deferred to v0.2

A v0.1 float literal must contain a decimal point and admits no exponent (§2.5). The restriction is at the family lexer, so it binds every profile, and rmdl is where it is felt: gains, tolerances and small time constants are where exponent notation earns its place.

Deferred to v0.2, as a family lexer change. Precondition: the exponent form must not collide with the number-plus-unit-atom merge the same lexer performs. That merge is restricted to the UCUM time atoms (us, ms, s, min, h), none of which begins with e, but UCUM has atoms that do (eV). The check before the pattern changes is that no literal position in the v0.2 grammar admits a bare unit atom after a number.

17.7 Value-Rule Expressions — deferred to the family expr core

typl v0.1 constraints are deliberately closed-form: literal/const ranges, step, length bounds, and match patterns. Everything requiring a general expression language is deferred to the family expr core and will arrive with require/ensure: (a) arithmetic in constraint bounds ([0.0..MAX_SPEED * 0.5] — v0.1 bounds accept only literals and plain constant references); (b) predicate constraints on a single value (value % 2 == 0); (c) regex matching as a predicate operator — declarative match on string types exists today; when expr lands, the same match keyword serves infix in predicates (require vin match VIN_PATTERN): one keyword, one concept (pattern conformance), two positions. (A ~ sigil was considered and rejected — the family is deliberately sigil-poor, and match reads as English to non-programmer audiences); (d) cross-field invariants on structs (min <= max) — the likely surface is a struct-level invariant block completing the Eiffel triad beside ridl's require/ensure.

Rationale for the deferral: closed-form constraints keep every typl type decidable for width inference and mechanically derivable into property-test generators; general expressions in constraint position need fencing rules (const-evaluable subset only) that belong to the expr-core specification, not here. TYPL-303 already rejects require/ensure in a .typl context, which is the boundary this deferral protects.

The target is anchored. The expr core is the expr-core Specification (family overview §2; concept note open question 6), whose guaranteed subset ships with the interface layer and whose function layer is not built. Sub-items (a) to (c) are constraint-position expressions and are the expr core's entirely. Sub-item (d)'s expression language is likewise the expr core's, but its surface — a struct-level invariant block — is a typl declaration shape and lands in §7 when the expr core does.

17.8 Standards-Fixed Invalid Sentinel Values — deferred to v0.2

Most of the original question is resolved and lives elsewhere. The init half is §5.8 (a declared bare = value, or derived). The invalid half is handled at the interaction layer: ridl §4.5 propagates invalidity as channel state, with the SNA sentinel as its CAN/AUTOSAR wire realisation. Where the codegen is free to choose the value, ADR-0013 decision 7 settles it and no typl syntax is needed — ? declares the absence, the backend picks a value the declared range does not use and does not surface it in the generated API, and where the range has no room the backend fails with a diagnostic.

What remains open is the case where the backend may not choose, because a published standard has fixed the value. Two sub-cases are known and are the inputs to the decision:

  • a single fixed value, as in a brownfield DBC signal whose SNA encoding is already deployed;
  • several fixed values with distinct meanings, as in the ETSI ITS vehicle-length data type, which separates "longer than this field can express" from "not measured". One reserved value cannot carry both, and a consumer can act on the first while it can only abstain on the second. This is a contract fact rather than a backend fact, which is why it is not pushed to a backend annotation.

Deferred to v0.2. It is answerable when a conformance target exists: modelling one published message end to end — a Cooperative Awareness Message is the candidate — is the way to test a proposal before it becomes syntax.

17.9 Byte Order — resolved for typl; the deployment surface is rsdl v0.2

CAN/DBC signals carry per-signal byte order (Intel/Motorola) and SOME/IP defaults big-endian, and typl was silent.

Resolved for typl: byte order is not a type property. The rule is §4.6.

Deferred, rsdl v0.2: the surface by which a deployment states the byte order of a transport that carries a choice belongs to rsdl, and lands with the rsdl rewrite at v0.2 (family overview decision ledger #36). A typl rule cannot be written against rsdl surface that does not exist yet, which is why this half is named rather than written here. The same deployment surface is what §7.4's mixed-version decoder rule needs on a transport that cannot carry unknown trailing fields.

17.10 Canonical (Deterministic) Encoding — resolved; the frame specification and each projection record own the rest of the rule

This question is resolved, and its entry stays below because the frame specification and ADR-0014 cite this section.

Signed payloads, content-hashing of data, and the replay/test plane all want a bit-reproducible encoding (protobuf's deterministic-marshal caveats are the cautionary tale; map ordering is the classic leak).

One correction to this question as it was first written. It guessed the answer was "likely an IR-spec concern". The IR's own canonical form is settled — ADR-0014 decision 9 makes canonical protobuf JSON the canonical encoding, with binary and prototext derived (that clause named binary until decision 9's 2026-09-22 amendment; the policy is the IR specification) — and that is a different artifact. This question is about the payload encoding a contract's values travel in, which ADR-0014 does not touch.

Resolved for typl: a canonical payload encoding is not a type-layer property, and the one ordering typl controls is a map's key order. The rule is §12.2.

Placed: a reproducible byte sequence for a transmitted unit is a property of an encoding and its frame, so the rule belongs to the frame specification and to each projection record, not to this reference. Written 2026-09-22: that document is the frame specification, whose §4 makes the frame header canonical and leaves the payload's part to each projection record.

17.11 Explicit Wire-Width Floor — a deferred wire clause, v0.2

v0.1 has no surface syntax for wire width (§5.6): a range is the width, inferred once by the compiler, and the ten concrete width names (uint8 … float64) are not writable anywhere. This leaves one hazard unhanded at the source: widening a range across a width boundary silently flips the wire type (uint8 → uint16), a wire ABI break that today only the ridl-diff CI gate catches.

A future wire clause would let an author pin an explicit width floor above the boundary — type Counter : integer [0..250] wire uint16 — reserving evolution headroom so a later widening does not flip the transport encoding.

Design constraints already settled, for whenever it lands: the named width must be at least the inferred width and of the same signedness class (narrower or sign-incompatible is an error); it never changes the language-layer type (int64/float64) or the value constraints — the range still validates, only the transport encoding widens; and it is a floor, never a parallel type system (ranges remain the semantic truth). Whether the floor should also be declarable per-field, and how it interacts with the scaled-integer wire form of quantized floats, are the two rules a real contract should shape.

Deferred to v0.2. The ridl-diff gate already prevents the silent break in CI, and the clause is pure evolution ergonomics. The forcing case that reopens it is ADR-0013 decision 6's: a deployment that needs to widen a range on a FlatBuffers-bound contract without a coordinated flag day.

One catalogue entry moves with the clause. TYPL-112 makes a concrete wire-width name written in source an error (§16.2). A wire clause makes that rule conditional — the name becomes writable in exactly one position — so the two are reconciled together when the clause lands.

The half that is not deferred is the consequence a fixed inline layout carries, which is now stated in §5.6: a resolved width change is a layout break as well as a wire break, and ridl-diff classifies it breaking on both counts. No encoding has to wait for the clause to know that.

17.12 Integer-Backed Unit Types — deferred to v0.2

A unit type's backing would be the literal type of its range: integer literals give an integer backing, float literals give a float backing, and a unit type with no range keeps today's float default, so a range-less declaration is unchanged. Latency : ms [0..5000] is then an integer type carrying a unit, and Timestamp : us and Duration : ms become declarable in typl as the integers a runtime library already makes them.

The grammar already distinguishes int_lit from float_lit, and TYPL-105 already rejects a step whose literal type differs from the range's, so a backing cannot flip unnoticed by editing one number wherever a step is declared; where none is, ridl-diff is what sees the change, because the backing reaches the IR. The domain type, the width and every encoding follow the rules plain integer already has, and the unit algebra of §17.5 is untouched. An explicit backing keyword was rejected: TYPL-105 already makes the flip explicit, and this is the rule every language uses for 1 against 1.0.

Deferred to v0.2, and it is not a v0.1 change. §5.1 fixes the backing of a unit type to float, so the new rule changes the backing, the inferred width and the IR of every unit type already declared with integer literals, and ridl-diff classifies each as a breaking width change. A point release that turns every such published contract into a breaking diff is not a point release. The corpus check runs before the change lands, not after: every unit type written with integer literals today is either a wanted integer or a missing .0. Recorded in docs/wip/2026-09-12-release-scope-and-plugin-system-design.md §3.10.

17.15 A Reorder in the Same Edit as an Addition or a Removal — deferred to v0.2

ridl-diff's walk reports member_reordered only when both bodies hold the same member names; with a member added or removed, the report carries the addition or removal alone and does not name the survivors that moved.

Until it is taken, the report is incomplete rather than wrong. A member inserted in the middle of a struct or union body, with every later member left in its written order, shifts the ordinal of every member after it and is reported as one decl_added, which the classifier judges breaking under §7.4's append-only rule — the classifier reads the body to judge the addition's direction, rather than relying on the reorder walk. An enum value or enumset bit inserted mid-body shifts no number and is judged by its value (§8, §9). A member removed is one decl_removed, breaking, whether or not a tombstone replaced it.

Deferred to v0.2, sequenced behind driftsys/ridl#302. Widening the case reaches the name-keyed matching logic that crates/ridl-diff/src/walk.rs's carried-debt comment guards, and #302 records why that logic cannot move alone: on FlatBuffers a union arm's discriminant is implicit in declaration order, so a tombstoned retirement that the widened logic would call compatible shifts every later arm. #302 also records why the backend cannot move alone — explicit union member values pin the discriminants, but flatc 25.12.19 accepts the form while planus 1.3.0 rejects it, so taking it would put the union path outside the validity oracle. The blocking constraint is that oracle gap, and no edit to this reference removes it.


Appendix A — Standard Library

ridl.std is a pure typl package — the proof that typl stands alone. It is implicitly imported in every file of every profile.

Inclusion criterion. ridl.std holds only definitions whose form is fixed by a cross-industry standard and whose meaning does not depend on the domain the system models. A definition that is meaningful in one industry and inert in the others belongs in a domain package. The criterion follows from the implicit import: every file of every profile carries whatever this package holds, so a domain type here is a cost paid by every user who does not work in that domain, and admitting one leaves no principled place to stop.

Applying that criterion removed the ISO 3779 Vin type and its VIN_PATTERN constant, which this appendix had inherited unexamined from the RIDL Language Reference v0.1. A vehicle program declares both in two lines of its own package, and no other member of ridl.std is bound to a single industry.

Contents (normative):

package ridl.std

// ---------- Regex Constants ----------

/// Pattern of a UUID: five groups of 8, 4, 4, 4 and 12 lowercase hexadecimal digits
const UUID_PATTERN  = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
/// Pattern of a ULID: 26 Crockford base32 characters, the first one 0 to 7
const ULID_PATTERN  = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/
/// Pattern of a URI: a scheme, `://`, then at least one character
const URI_PATTERN   = /^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\/.+$/
/// Pattern of a URL: `http://` or `https://`, then at least one character
const URL_PATTERN   = /^https?:\/\/.+$/
/// Pattern of an email address: a local part, `@`, and a domain that contains a dot
const EMAIL_PATTERN = /^[^@]+@[^@]+\.[^@]+$/
/// Pattern of an IPv4 address: four groups of 1 to 3 digits, separated by dots
const IPV4_PATTERN  = /^(\d{1,3}\.){3}\d{1,3}$/
/// Pattern of an IPv6 address: lowercase hexadecimal digits and colons
const IPV6_PATTERN  = /^[0-9a-f:]+$/
/// Pattern of a string of printable ASCII characters
const ASCII_PATTERN = /^[\x20-\x7E]+$/

// ---------- Identity Types ----------

/// RFC 4122 UUID — 36 characters including hyphens
type Uuid : string [36 match UUID_PATTERN]

/// ULID — 26 characters, lexicographically sortable
type Ulid : string [26 match ULID_PATTERN]

// ---------- Network Types ----------

/// RFC 3986 URI
type Uri   : string [1..2048 match URI_PATTERN]

/// HTTP/HTTPS URL
type Url   : string [1..2048 match URL_PATTERN]

/// Email address — RFC 5321
type Email : string [1..254 match EMAIL_PATTERN]

/// IPv4 address — dotted decimal notation
type IpV4  : string [7..15 match IPV4_PATTERN]

/// IPv6 address
type IpV6  : string [2..39 match IPV6_PATTERN]

// ---------- General String Types ----------

/// Short human-readable label — ASCII printable, max 64 characters
type Label   : string [1..64 match ASCII_PATTERN]

/// Human-readable name — max 128 characters
type Name    : string [1..128 match ASCII_PATTERN]

/// Short message or description — max 256 characters
type Message : string [1..256 match ASCII_PATTERN]

// ---------- Time Types ----------

/// Absolute point in time — platform time domain (ridl §3.1):
/// int64 microseconds since the PTP epoch, 1970-01-01 00:00:00 TAI.
/// Continuous and leap-second-free; convert to civil datetime only at
/// presentation edges.
type Timestamp : integer [0..9223372036854775807]

/// Elapsed time interval in milliseconds
type Duration  : ms [0.0..9223372036854775807]

/// Calendar date — ISO 8601 YYYY-MM-DD
type Date : string [10 match /^\d{4}-\d{2}-\d{2}$/]

/// Time of day — ISO 8601 HH:MM:SS
type TimeOfDay : string [8..15 match /^\d{2}:\d{2}:\d{2}/]

// ---------- Version ----------

/// Semantic version — SemVer 2.0.0
type Version : string [5..32 match /^\d+\.\d+\.\d+/]

// ---------- Locale ----------

/// ISO 3166-1 alpha-2 country code
type CountryCode : string [2 match /^[A-Z]{2}$/]

/// ISO 639-1 language code with optional region — e.g. "en", "en-US"
type LanguageCode : string [2..5 match /^[a-z]{2}(-[A-Z]{2})?$/]

// ---------- Crypto / Security ----------

/// SHA-256 hash — 32 bytes
type Sha256Hash  : bytes [32]

/// SHA-512 hash — 64 bytes
type Sha512Hash  : bytes [64]

/// Generic cryptographic signature — 64 bytes
type Signature   : bytes [64]

/// X.509 DER certificate
type Certificate : bytes [1..4096]

Appendix B — Full Example

A complete pure-typl package — veh/common/, profile-pure, generating types across every backend with no interface content:

package veh.common

// ---------- Units and scalars ----------

/// Vehicle speed over ground
type Speed       : km/h [0.0..MAX_SPEED step 0.5]

/// Coolant / ambient temperature
type Temperature : Cel  [-40.0..125.0 step 0.1]

/// Engine crankshaft speed
type RPM         : /min [0.0..8000.0 step 10.0]

/// Normalised ratio
type Ratio       : %    [0.0..100.0 step 0.1]

type Counter : integer [0..65535]
type Gain    : float   [0.0..1.0 step 0.01]

// ---------- Constants ----------

const MAX_SPEED      : Speed   = 250.0
const SPEED_LIMIT_EU : Speed   = 130.0
const MAX_GEAR       : integer = 6
const IDLE_RPM       : RPM     = 800.0

// ---------- Enums ----------

enum GearPosition {
  PARK    = 0
  DRIVE   = 1
  REVERSE = 2
  NEUTRAL = 3
}

enum Warning {
  LOW_FUEL     = 0
  CHECK_ENGINE = 1
  DOOR_OPEN    = 2
  SEATBELT     = 3
}

enumset WarningFlags : Warning

// ---------- Composites ----------

struct SpeedLimitPayload {
  limit  : Speed
  actual : Speed
}

struct DriverProfile {
  name     : Name              // ridl.std
  speed    : Speed
  override : Speed?            // optional
  gears    : integer [0..MAX_GEAR] = 6
}

struct SensorBounds {
  range    : (min: Speed, max: Speed)
  readings : [Speed; 8]
  labels   : [Label; 1..16]
  meta     : [Label : Name; 0..32]
}

union SensorResult {           // result union — §10.2: one success arm, one error arm
  ok  : SensorReading
  err : SensorFault
}

struct SensorReading {
  value     : Speed
  timestamp : Timestamp        // ridl.std
}

error struct SensorFault {     // failure vocabulary — §10.1
  code    : Counter
  message : Message            // ridl.std
}

// package-private helper — not visible to importers, omitted from
// cross-package codegen artifacts
internal struct RawWheelFrame {
  ticks : Counter
  frame : bytes [8]
}

Appendix C — Standards References

StandardUsed for
Unicode 15.0source encoding
RFC 3629UTF-8
RFC 8259string escapes
ECMA-262regex literal syntax, restricted to the patterns the Rust regex crate also compiles (§2.7)
CommonMarkdoc comment markdown
IEEE 754-2019floating point
ISO/IEC 9899:2018 (C17)integer/float literal conventions
UCUMphysical unit expressions
ISO 8601 / RFC 3339time types
RFC 3986, RFC 4122Uri, Uuid
SemVer 2.0.0Version
ISO 3779, ISO 3166-1, ISO 639-1Vin, CountryCode, LanguageCode
JSON Schema 2020-12coverage benchmark (Appendix G)

Appendix D — Codegen Targets

typl owns the type layer of every backend; interaction/behaviour codegen is specified by the higher profiles. Width mapping is resolved once by the compiler and passed to all backends (§4.2, §4.3, §9.3).

Language layer — always widest. The columns are the width rule per language, not a list of planned backends: Rust and TypeScript are built, Kotlin is an out-of-tree plugin, and C++ has no plan (ADR-0013 decision 1, as amended 2026-09-12).

CanonicalRustKotlinC++TypeScript
any integeri64Longint64_tbigint/number*
any floatf64Doubledoublenumber
enumseti64Longint64_tbigint

*TypeScript uses number when the range fits in 2^53, bigint otherwise.

Transport layer — narrowest that safely fits:

Canonicalproto3FlatBuffersSOME/IPCAN/DBCAIDL
uint8uint32*uint8UINT88 bitsint
uint16uint32*uint16UINT1616 bitsint
uint32uint32uint32UINT3232 bitsint
int32sint32int32SINT3232 bits signedint
int64sint64int64SINT6464 bits signedlong
float32floatfloat32FLOAT32 bitsfloat
float64doublefloat64FLOAT6464 bitsdouble

*proto3 has no uint8/uint16 — varint encoding keeps small values small on the wire.

Per-Target Compatibility Notes

proto3. Unsigned 8/16-bit map to uint32 varint — no wire penalty for small values. Ranges containing negatives map to sint32/sint64 (ZigZag encoding); plain int32 varint would cost 10 bytes for every negative value. Field numbers are the typl ordinals (§7.4); reserved tombstones emit proto reserved statements. Range widening never breaks proto wire (varint absorbs it) but remains a contract-breaking change per ridl-diff.

FlatBuffers. Full uint8..uint64 palette — the cleanest width mapping. Field ids are the typl ordinals emitted as (id: N) attributes. FlatBuffers is the most width-brittle target: any resolved-width change is a hard wire break — the primary motivation for the wire floor deferred to v0.2 (§17.11); until it lands the ridl-diff gate is the sole guard, and it is also a layout break rather than only a wire break (§5.6). A struct whose fields are all fixed-width and non-optional may be emitted as a FlatBuffers struct (inline, zero indirection) instead of a table; the IR carries a fixed_layout flag for this. The built FlatBuffers projection does not take that allowance (ADR-0019 decision 3): a FlatBuffers struct has a fixed inline layout and no vtable, so after a compatible field append a reader with the newer schema reads past what an older writer wrote and returns the appended field fabricated from padding, where a table reports it absent. Every struct is therefore emitted as a table. The fixed_layout flag stays in the IR for a target where a fixed layout is safe.

AUTOSAR Classic (ARXML). The best structural fit: range → DataConstr, quantized float → LINEAR CompuMethod (factor = step, offset = min) — the scaled-integer wire form (§4.3) is native here. Two gaps become profile rules: Classic sender-receiver records have no optional fields — a struct containing ? bound to a Classic/CAN transport is a codegen error, never silently default-filled; and Classic has no native tagged union — a union on a Classic-bound payload is a codegen error in v0.1 (a generated tag + padded-body convention is a possible later opt-in). A normative UCUM → AUTOSAR unit / physical-dimension mapping table is required and does not yet exist — open item.

CAN / DBC. Signals are scaled integers by construction — factor, offset, min, max, and bit length all derive from range + step. Strings, bytes, maps, and anything unbounded cannot bind to CAN; bounded arrays flatten to indexed signals. Optionality does not exist on CAN — same profile rule as Classic.

SOME/IP. Positional serialization — ordinals matter only as declaration order, which §7.4 freezes anyway. Width flips shift the byte layout: breaking, caught by ridl-diff. Quantized-float-as-scaled-integer is available as a per-deployment choice (an rsdl concern, not a type property).

Schema targets. Because typl is a schema language, schema languages are themselves backends: JSON Schema 2020-12 (each named type → $defs entry; ranges → minimum/maximum, step → multipleOf, match → pattern, structs → closed objects with additionalProperties: false), OpenAPI/AsyncAPI component schemas, ARXML (<AR-PACKAGE> chain, CompuMethods from unit types), DBC (scaling/offset derived from range and step). The JSON Schema emitter is the round-trip check on Appendix G.


Appendix E — Formal Grammar (EBNF)

The typl profile grammar — the restriction of the family grammar accepted in .typl files. Family constructs not derivable here (interactions, timing, streams, behaviour, architecture) are rejected with TYPL-3xx.

(* Top-level *)
file          = package { import } { definition } ;

package       = "package" qualified_id ;

import        = "import" qualified_id [ "as" id ] ;
              (* no wildcards, no relative paths, no re-exports — ADR-0002 *)

definition    = [ "internal" ] [ "error" ] ( type_def | const_def | struct_def
                               | enum_def | enumset_def | union_def ) ;
              (* "error" valid on struct_def, enum_def, union_def only — §10.1 *)

(* ---------- Type ---------- *)

type_def      = doc_comment? "type" CamelCase_id ":" type_backing constraint? init_value? ;

init_value    = "=" literal ;                       (* declared init value, bare — §5.8 *)

type_backing  = "integer" | "float" | "string" | "bytes" | "boolean" | ucum_unit ;

ucum_unit     = (* UCUM expression: km/h, Cel, N.m, /min, %, bar, V, A ... *) ;

constraint    = "[" constraint_spec "]" ;

constraint_spec
              = scalar ".." scalar ( "step" scalar )?
              | scalar ".."
              | ".." scalar
              | int_lit
              | int_lit ".." int_lit
              | int_lit "match" pattern
              | int_lit ".." int_lit "match" pattern
              | "match" pattern
              ;

pattern       = regex_lit | SCREAMING_SNAKE_ID ;
scalar        = int_lit | float_lit | SCREAMING_SNAKE_ID ;   (* constants usable as bounds *)

(* ---------- Constants ---------- *)

const_def     = doc_comment? "const" SCREAMING_SNAKE_ID ( ":" type_ref )? "="
                ( literal | regex_lit ) ;

(* ---------- Struct ---------- *)

struct_def    = doc_comment? "struct" CamelCase_id "{" { ( field | reserved ) sep? } "}" ;
field         = doc_comment? camelCase_id ":" field_type init_value? ;
reserved      = "reserved" ( camelCase_id | int_lit ) ;   (* tombstone — §7.4 *)

field_type    = "boolean"
              | ( "integer" | "float" ) constraint?      (* inline constrained primitive;
                                                            string/bytes need a named type — §15.3 *)
              | type_ref
              | tuple_type
              | field_type "?"
              | "[" field_type ";" bound "]"                       (* array *)
              | "[" key_type ":" field_type ";" bound "]"          (* map *)
              ;

bound         = int_lit | int_lit ".." int_lit ;
key_type      = type_ref | "string" | "integer" ;
tuple_type    = "(" named_field { "," named_field } ")" ;
named_field   = camelCase_id ":" field_type ;
type_ref      = qualified_id ;                                     (* Type or pkg.Type *)

(* ---------- Enum ---------- *)

enum_def      = doc_comment? "enum" CamelCase_id "{" { ( enum_value | reserved ) sep? } "}" ;
enum_value    = doc_comment? SCREAMING_SNAKE_ID "=" int_lit ;

(* ---------- EnumSet ---------- *)

enumset_def   = doc_comment? "enumset" CamelCase_id "{" { enumset_bit sep? } "}"
              | doc_comment? "enumset" CamelCase_id ":" type_ref
              ;
enumset_bit   = doc_comment? SCREAMING_SNAKE_ID "=" int_lit ;

(* ---------- Union ---------- *)

union_def     = doc_comment? "union" CamelCase_id "{" { ( union_arm | reserved ) sep? } "}" ;
union_arm     = doc_comment? camelCase_id ":" type_ref ;

(* ---------- Doc Comments ---------- *)

doc_comment   = "/**" { doc_tag | markdown_text } "*/"
              | { "///" markdown_text newline } ;

doc_tag       = "@see" qualified_id
              | "@since" version
              | "@labels" label { "," label }
              | "@deprecated" string_lit ;

version       = int_lit "." int_lit [ "." int_lit ] ;

label         = SCREAMING_SNAKE_ID [ "(" SCREAMING_SNAKE_ID ")" ] ;

(* ---------- Separators, Identifiers, Literals ---------- *)

sep                = "," | newline ;
qualified_id       = id { "." id } ;
id                 = CamelCase_id | camelCase_id ;
CamelCase_id       = [A-Z][a-zA-Z0-9]* ;
camelCase_id       = [a-z][a-zA-Z0-9]* ;
SCREAMING_SNAKE_ID = [A-Z][A-Z0-9_]* ;

int_lit       = "-"? [0-9]+ ;
float_lit     = "-"? [0-9]+ "." [0-9]+ ;
string_lit    = '"' { utf8_char } '"' ;
regex_lit     = "/" { regex_char } "/" ;
bool_lit      = "true" | "false" ;
literal       = int_lit | float_lit | string_lit | bool_lit ;

Appendix F — Prior Work: markspec typl

The first typl was drafted as a MarkSpec extension — a Type Specification DSL binding $Name identifiers inside requirements entries to a kind and a shape (markspec typl spec). That work validated the core idea (a small, closed, range-first shape grammar with a coded diagnostic catalogue) in a live toolchain (parser, LSP hover/completion, JSON compile output with a corpus-level typeRegistry). MarkSpec also carries uxil, the precursor of uxdl and rxdl — relevant to the rxdl vocabulary workshop, not to this document.

This specification is the successor, not a superset. The mapping:

markspec typlfamily typl (this spec)Disposition
$Name : kind shape bindings with sigilnamed declarations in packages (type, const, struct, …)sigil dropped — names live in the module system, not in prose
kind vocabulary: signal, event, command, streamnot typl — ridl's interaction keywords over the interact coremoved up a layer
kind statenot typl — rmdl territorymoved up a layer
kind configridl fixed (provisioned constant)moved up a layer — fixed confirmed over config: it names the consumer contract (immutable, cacheable), not the provider workflow; see concept-note naming ledger
kind constconstkept
kind value (default)type / struct fieldkept, made explicit
kind documentstructfolded in
kind namespace + $. relative refs + published tierpackage / import (ADR-0002 ns core)superseded — packages give declared-once, absolute citation, and ownership corpus-wide
int[0..300], float[0..1.0] postfix rangesinteger [0..300], float [0.0..1.0 step s]kept in spirit; family name : Type [constraint] syntax; step added
string[3..6], bytes[32] length shapesstring [3..6], bytes [32]kept
pattern /re/ shapematch /re/ inside the string constraintmerged into the constraint; named regex constants added
'low' | 'mid' | 'high' literal-union enumsenum (integer-backed)not carried over — string enums are planned for v0.2 (§17.1)
{ field: shape } recordsstructkept; structs are named and closed
element[](min..max) arrays[T; min..max]kept; bounds made mandatory
shape? optionalfield : T?kept
typedef type Name = shapetype Name : backing [constraint] (scalars) / named compositessplit by composite kind
entry-local scope tiernot applicable — no prose entries; packages are the only scopesuperseded
TYPL-001…012 diagnostic catalogueTYPL-0xx…4xx coded catalogue (§16)practice adopted, codes renumbered by area
four Markdown surfaces (fence, bullet, inline, table)not adopted — typl lives in .typl filesMarkSpec remains a consumer: a requirements corpus can cite typl symbols, and a future markspec bridge can validate ```ridl fences against a workspace

What survives unchanged in spirit: closed vocabularies over extensible ones, ranges as first-class shape (not an afterthought annotation), a small shape grammar a reader can hold in their head, and machine-checked diagnostics with stable codes.


Appendix G — Coverage Analysis: JSON Schema and Other Schema Languages

G.1 Method

typl claims to be a schema language, so the benchmark is: can typl express what a contract author reaches for in JSON Schema 2020-12 — the latest published dialect (the in-progress "stable" rewrite does not change the validation vocabulary materially)? The matrix covers the entire 2020-12 validation and annotation vocabulary. ✓ covered, ≈ covered differently (usually stricter), ✗ not expressible (deliberate or open).

G.2 JSON Schema 2020-12 coverage matrix

JSON Schema keywordtypl equivalentStatus
type: boolean/integer/number/stringboolean, integer, float, string✓
type: null / nullable? optionality (absence; typl has no null value)≈ stricter
type: array[T; N], [T; min..max]✓ bounds mandatory
type: object (free-form)struct (closed) or [K:V; min..max] map≈ stricter
minimum / maximum[min..max]✓
exclusiveMinimum / exclusiveMaximum—✗ deliberate — not supported and not planned (§5.5)
multipleOfstep (quantization anchored at range min)✓
minLength / maxLengthstring [min..max]✓
patternmatch /re/ or named regex const✓ + reusable named patterns
format (uuid, email, uri, ipv4, date, …)ridl.std named types (Uuid, Email, Uri, IpV4, Date, …)✓ and normative (JSON Schema format is annotation-only by default)
contentEncoding / contentMediaTypebytes + doc comment≈ partial
minItems / maxItems[T; min..max]✓ mandatory
uniqueItems—✗ v0.2 (§17.3) — an array constraint, not a set container
prefixItems (positional tuple)named tuples (a: T, b: U)≈ named, not positional — deliberate
contains / minContains / maxContains—✗ deliberate (validation logic, not shape)
properties + requiredstruct fields; optional via ? (required is the default)✓ inverted default, stricter
additionalProperties: falseimplicit — structs are closed✓ by construction
additionalProperties: <schema> / patternPropertiesbounded map [K:V; min..max] with match-constrained key type≈
propertyNamesmap key = named string type with match✓
minProperties / maxPropertiesmap bounds✓
dependentRequired / dependentSchemas / if/then/else—✗ deliberate: conditional shape is undecidable for width inference and hostile to certification; cross-field invariants belong to expr contracts in higher profiles (§17.7)
enum (numeric)enum✓
enum (string literals)—✗ v0.2 (§17.1) — integer wire form with a declared rendering
const (fixed value in schema position)const declarations; fixed-value field via [N] exact forms≈
oneOf (discriminated)union (tagged)✓ stricter — tag is structural
anyOf / allOf / not—✗ deliberate: open-world combinators break the faithful-codegen guarantee (no clean proto/FlatBuffers/DBC mapping)
unevaluatedItems / unevaluatedPropertiesirrelevant — structs closed, arrays homogeneous by construction✓ by construction
$ref / $defsnamed types + qualified imports✓ stronger: versioned, package-scoped, cycle-checked
$id / $anchor / $dynamicRefpackage + name is the identity≈ superseded by module system
recursive schemas—✗ deliberate (§7.3): unbounded wire size
title / descriptiondoc comments✓
defaultbare = v (init value, §5.8)✓ type-checked (JSON Schema does not validate default)
deprecated@deprecated "reason"✓ reason mandatory in practice
examples— (doc-comment code blocks)≈
readOnly / writeOnlynot a type property — direction lives on interactions (ridl fixed, signal direction)≈ relocated by design
vocabularies / meta-schemas / $schemaone closed grammar + label profiles✗ deliberate — no user-extensible validation vocabulary

Verdict. typl covers the JSON Schema constructs that describe data shape — everything a codegen target can faithfully realise — and adds what JSON Schema lacks: physical units, quantization (step as more than multipleOf — it drives width inference), wire-width inference, bitfields (enumset), a real module system with declared-once semantics, and type-checked init values. What it refuses is JSON Schema's validation-logic stratum (conditionals, combinators, contains, unevaluated*) — deliberately, because those constructs have no faithful mapping onto proto/FlatBuffers/CAN and defeat static width/size guarantees. Of the four gaps this appendix once tracked as open, two are now closed by decision — exclusive bounds (§5.5) and recursion (§7.3) — and two are planned for v0.2: string enums (§17.1) and uniqueItems as an array constraint (§17.3).

G.3 Other prior art consulted

LanguageWhat typl takes / how it differs
ASN.1 (X.680) with constraintsthe closest ancestor in spirit: subtype constraints (INTEGER (0..255)) driving encoding width (PER) is exactly typl's range→width inference. typl trades ASN.1's enormous surface for a closed 6-declaration grammar
CDDL (RFC 8610)compact shape DSL over CBOR; ranges and .size/.regexp control operators parallel typl constraints; CDDL allows unbounded recursion — typl does not
XSDfacets (minInclusive, pattern, length) map to typl constraints; typl rejects XSD's inheritance/extension model in favour of composition
Protocol Buffersthe codegen contract baseline; typl is deliberately richer (ranges, units, bounds) precisely where proto is silent, and its emitter targets proto3 (Appendix D)
FlatBuffers / Avro / Thriftfixed-width scalars and required bounds validate typl's transport-layer mapping; Avro's named-types + namespace model parallels the package system
Franca IDL / AUTOSAR ARXMLautomotive precedent: ARXML CompuMethods and data constraints are the target of typl's unit types and ranges; Franca's typeCollection ≈ a pure typl package
DBCsignal scaling/offset/min/max are derivable from unit type + range + step — the rest-bus generation path
TypeSpec (Microsoft)a modern "one schema source, many emitters" language — same architecture as typl→IR→backends; TypeSpec chooses an open decorator/extensibility model where typl chooses a closed grammar with label profiles
CUEvalues-as-types and lattice unification are powerful but undecidable for width inference; typl keeps constraints first-class but static
Zod / valibotruntime-validator ergonomics (.min(), .regex(), branded types) confirm the demand for constraint-first typing; typl moves the same checks to compile time and codegen

Appendix H — Glossary

TermDefinition
RIDL (capitals)the platform and family name; typl is this language, the family's vocabulary layer and root of the lattice
familythe four languages — typl, ridl, rmdl, rsdl — sharing one grammar, one toolchain, one IR; rxdl is a profile and a spelling layer over ridl, not a fifth language
profilethe restriction of the family grammar accepted by a file extension; .typl accepts type declarations only; .rxdl is the total profile accepting every layer
profile puritythe ridl.toml policy restricting a package to one profile's declarations — enforced per package, not by the grammar
corea reusable semantic unit beneath the surface languages: ns (namespacing), typl-core (types), expr (predicates), time (timing), interact (interactions)
vocabulary layertypl's role: the types, units, ranges, constants, and composites every other layer describes things with
packagethe unit of namespace, visibility, versioning, and codegen — a directory whose files share one package declaration (ADR-0002)
workspacea coordinated set of packages with one lockfile and one [workspace] manifest
manifest / lockfileridl.toml (declares imports, profile purity, defaults) / ridl.lock (pins remote imports by content hash)
primitiveone of the five built-in scalars: boolean, integer, float, string, bytes — backing material, restricted as direct field types
named typea type declaration: a constrained primitive or unit type carrying domain meaning; the normal building block for fields
backingthe primitive (or UCUM unit, itself float-backed) underneath a named type
unit typea named type whose backing is a UCUM unit expression (km/h, Cel) — carries dimensional identity into every layer
UCUMUnified Code for Units of Measure — the unit vocabulary standard (also used by OpenTelemetry, HL7 FHIR)
constraintthe [ ] clause on a type: range, step, length, match — closed-form by design (no general expressions, §17.7)
rangeclosed (inclusive) [min..max] bounds; the semantic truth from which wire width is inferred
stepquantization: valid values are min + n·step; drives float width inference and the scaled-integer wire form
quantized floata float with range + step — losslessly representable as a scaled integer (raw = (value − min)/step), the CAN/AUTOSAR wire form
matchthe constraint keyword binding a string type to a regex pattern (named const or inline literal)
width inferencethe compiler deriving the narrowest safe wire type from a declared range/step — computed once, passed to all backends
language layerthe generated in-code representation: always widest (int64/float64) so arithmetic cannot overflow storage
transport layerthe on-wire representation: narrowest width the range provably fits
wire typea concrete transport width (uint8 … float64); inferred from the range and never written in source — the names are not typl keywords (§5.6)
width flipthe evolution hazard where widening a range crosses a width boundary and silently changes the wire type — caught by ridl-diff; a source-level pre-emption (a deferred wire floor) is §17.11
nominal typing§5.7: every named type is distinct from its backing and from all other types; no implicit conversion — the foundation of unit safety
structa named composite with a fixed, closed set of named fields — no unknown properties, ever
optional (?)a field that may be absent (typl has no null); inverted from JSON Schema — presence is the default
init valuethe value a consumer holds before anything real arrives — declared via a bare = value suffix or derived per §5.8; the seed of every ridl signal channel. Carries no keyword at the typl/ridl layer (default retired; init is rmdl's memory-seed keyword only)
enum / enumsetclosed set of integer-backed values (single selection) / named bitfield (multiple simultaneous flags, width from highest bit)
uniondiscriminated (tagged) composite — exactly one named-type arm active; never untagged
error typean enum, struct, or union carrying the error modifier — failure vocabulary; ordinary data with one extra bit of meaning
result uniona two-arm union of one success arm + one error-typed arm — the family's Result<T, E> without generics; the entire functional-error mechanism
tupleanonymous composite of named fields used inline; () is the unit type
boundedthe invariant that every typl composite has a statically known maximum wire size — why collections require bounds and recursion is an error
ordinala field's/arm's implicit 1-based declaration-order identity — the source of proto field numbers and FlatBuffers ids (§7.4)
reservedtombstone keeping a retired field's ordinal (or enum wire value) occupied so it is never reused
append-onlythe evolution discipline: new fields at the end, deletions by tombstone, reorder = wire break caught by ridl-diff
one-definition rulea public name is declared exactly once, in exactly one package, importable everywhere — no re-exports, no wildcards
internalpackage-private visibility; the opt-out from public-by-default
diagnostic codea stable TYPL-nnn identifier for a compiler rule — never renumbered, never reused (practice inherited from markspec typl)
IRthe stable serialized intermediate representation — resolved names, checked types with ranges/units/widths intact — consumed by every backend and tool
ridlc / ridl / ridl-diffthe compiler (plumbing, pure source→IR) / the toolchain facade (porcelain) / the IR-comparison breaking-change gate (plumbing-grade despite living in the facade)
profile (assurance)an external plug-in validating @labels vocabulary and escalating optional rules (explicit ranges/bounds) to errors — distinct from grammar profile
SSOTsingle source of truth — one vocabulary from which types, validators, schemas, and documentation derive across every backend

End of typl Language Reference v0.1.0.