Type System Rules
Tsonic is intentionally strict.
Core rule
If a construct cannot be lowered deterministically, it is rejected.
That applies to:
- unsupported dynamic behavior
- unresolved
any-like escapes - unsupported open-ended runtime typing paths
- ambiguous lowering cases
What “strict” means here
Strictness is not just about TypeScript flags. In Tsonic it also means:
- no silent runtime bridge for unsupported shapes
- no best-effort lowering when overload selection is ambiguous
- no hidden weakening of types to make emission “just work”
Numeric intent
Use @tsonic/core/types.js for CLR-specific numeric/value intent:
import type { int, long, bool, double } from "@tsonic/core/types.js";
number is still available, but explicit CLR numeric types are important when
precision and overload selection matter.
Typical cases where explicit numeric intent matters:
- CLR overload selection
- interop with
Span<T>and other typed CLR APIs - APIs that distinguish
int,long,byte, and floating-point values
Numeric adaptation is contextual. A TypeScript integer literal can still lower to the CLR carrier required by the expected type.
Example:
type ParsedRange = { type: string; from: number; to: number };
export function range(size: number): number | ParsedRange {
if (size < 0) return -2;
return { type: "bytes", from: 0, to: size };
}
The -2 literal is syntactically an integer, but the union arm is TypeScript
number, whose CLR carrier is double. The emitter must therefore select the
numeric union arm and emit the equivalent of:
return Union<double, ParsedRange>.From1(-2);
It must not treat the value literal as compatible with the object arm, and it must not leave the value unwrapped when the method returns a runtime union.
Canonical type identity
Compiler type comparison is identity-based, not raw display-string based.
Bad comparison shape:
System.Span`1
global::System.Span<int>
Those strings are different spellings of the same CLR generic type shape. The compiler canonicalizes identity before comparing assignability, overload equivalence, structural membership, and runtime-union arms.
Source example:
export function copy(numbers: int[], destination: int[]): void {
const source = new Span<int>(numbers);
const target = new Span<int>(destination);
source.CopyTo(target);
}
Correct lowering keeps the Span<int> value direct:
source.CopyTo(target);
It must not insert an (object) bridge just because metadata and emitted C#
spelled the generic type differently. Ref-like CLR types such as Span<T>
cannot be boxed, so string-based identity comparison is a correctness bug.
Runtime union carriers
Runtime unions preserve their carrier family until a deterministic projection is required.
Example:
type Ok<T> = { success: true; value: T };
type Err<E> = { success: false; error: E };
type Result<T, E> = Ok<T> | Err<E>;
export function keepError(
result: Result<boolean, string>
): Result<boolean, string> {
if (!result.success) {
return result;
}
return { success: true, value: true };
}
After the guard, the local value is narrowed to the Err<string> arm. Returning
that arm from a function whose declared return is the full Result<boolean, string> carrier must re-wrap it:
return Result<bool, string>.From2(result_AsErr);
The narrowed arm is not the same value as the full union carrier. The compiler tracks that distinction by identity; it does not reverse-map narrowed temporary names back to the original source variable and assume they are already the full carrier.
Broad sinks such as object? do not force a runtime-union projection when the
actual emitted value has a direct CLR carrier. For example, a conditional whose
branches both emit string[] remains a direct array even if one semantic branch
was originally written as string[] | undefined.
Guards and narrowing
Branch narrowing is owned by the frontend and represented as a branch plan in IR. The emitter consumes that plan instead of rediscovering the guard from text.
Supported guard families include:
typeof value === "string"and other closed primitive checksvalue instanceof SomeClasswhen the right side is a proven constructible typeArray.isArray(value)when the narrowed carrier is an array carrier- literal-discriminant checks such as
result.success === true - string-literal property checks over proven dictionary carriers
Example:
type Ok = { success: true; value: string };
type Err = { success: false; error: string };
export function message(result: Ok | Err): string {
if (result.success === true) {
return result.value;
}
return result.error;
}
The frontend records that the true branch is the Ok arm and the false branch
is the Err arm. The emitter uses those arm identities directly when accessing
value and error.
Calls, constructors, and expected types
Call lowering is driven by the selected call target and the expected type of each argument. Numeric literals, object literals, callbacks, arrays, and union arms adapt to the selected parameter only when the compiler can prove the carrier.
import type { int } from "@tsonic/core/types.js";
declare function takeInt(value: int): void;
takeInt(42);
The literal is contextually adapted to int because the parameter target is
known. The same mechanism applies to constructors, extension-style calls,
callback return values, and generic call sites.
Loops and awaited values
for...of requires a proven iterable, array, or source-backed collection
carrier. await adapts the resolved value to the proven target type instead of
leaving it as a broad temporary.
export async function first(values: Promise<string[]>): Promise<string> {
const resolved = await values;
for (const value of resolved) {
return value;
}
return "";
}
The compiler proves the awaited carrier is string[], then lowers the loop over
that array carrier.
JSON and broad values
unknown is the broad boundary type. It may be stored and narrowed only through
deterministic carrier checks. It is not permission for reflection-style member
discovery.
type Payload = { title: string; count: number };
const payload = JSON.parse<Payload>('{"title":"docs","count":1}');
JSON.parse<T> and JSON.stringify<T> require closed compile-time types so the
compiler can root generated serialization metadata for NativeAOT.
Language intrinsics
Use @tsonic/core/lang.js for language-facing helpers:
import {
asinterface,
defaultof,
nameof,
out,
sizeof,
stackalloc,
trycast,
} from "@tsonic/core/lang.js";
Generics and strictness
The compiler favors deterministic generic behavior over permissive fallbacks. That means:
- no silent
anyescapes - no hidden runtime retyping bridge
- explicit rejection where lowering is not supported
This strictness is why downstream verification is necessary: many regressions show up only when real package graphs and emitted programs are exercised.