Types Destack extends TypeScript's type system with precise primitives, nominal types ("newtypes"), value types ("structs"), tuples, where-based constraints, and some additional niceties. Primitives Destack is based on TypeScript, and TypeScript inherits its main primitive types from JavaScript: string, boolean, number, bigint, and symbol, plus the null and undefined sentinels. Destack evolves this set into a serious set of primitive types: - precise numeric types beyond number, with variable-width signed and unsigned integers (int8, uint32, int17) as well as concrete float formats (float16, float32, float64) - pointer-sized integers, i.e. integers as wide as the target pointer size, spelled isize and usize - int and uint as aliases to int64 and uint64 - number becomes an alias for float, and float becomes an alias for float64 - char as a single Unicode scalar value, distinct from string It should be noted that string and bigint are not really "special" in Destack like they are in TypeScript, they are just aliases to the standard library String and BigInt classes. They feel the same, though. In general, Destack follows TypeScript behavior as exactly as possible for a sound and strict type system, including the exact same widening rules where numeric literals start as exact values and can flow into any numeric type that can represent them. const id: uint64 = 12345; 7 satisfies uint3; 7 satisfies uint2; // ERROR: 7 does not fit uint2 const exact: int = 42; exact satisfies int64; const balance: float = 100.50; balance satisfies float64; const n: number = 1.0; n satisfies float; const initial: char = 'A'; const input: unknown = readInput(); Unknown TypeScript has two "top" types: unknown and any can contain all other types. Of course, any is unsound, because everything can be assigned to and from any without any checks, so Destack forbids it in favor of the explicit unknown. unknown is really just a transparent constraint, and so it behaves like an interface with zero members under the regular representation rules: - In constraint positions, unknown induces an implicit generic: function parse(value: unknown) behaves like function parse(value: T) where the body knows nothing about T until it narrows. - In storage positions, bare unknown has no layout, so it must either induce a generic parameter or be erased behind Dynamic. function parse(value: unknown): string { if (value is string) { return value; // narrowed by the guard } todo("..."); } struct Event { payload: unknown; // induces existential T via Event } struct ErasedEvent { payload: Dynamic; // one fixed erased representation } For genuinely heterogeneous storage, Dynamic serves as the explicit erased universal value: a fat pointer carrying the value and its runtime descriptor, introspectable via reflection and narrowable (for runtime-discernible forms) via is. String Destack wants to be "TypeScript++", and thus we also follow JavaScript's string behavior: string length and positional access are defined in terms of UTF-16 code units, and - just like TS's own string iterator - iteration yields Unicode code points (mapping to char). const text = "héllo"; text.length satisfies usize; // UTF-16 code units, as in JS for (const c of text) { c satisfies char; // iteration by code point, as in JS } Destack is stricter than TS for string indexing: text[i] gives a char, and indexing into a lone surrogate traps (a lone surrogate is virtually always a bug, and the views below are the explicit alternatives). For the three main ways of looking at a string, the standard library provides explicit projections: | View | Type | Meaning | | --- | --- | --- | | text.units() | [uint16]-shaped view | raw UTF-16 code units (JS's indexing model) | | text.chars() | iterator of char | Unicode scalar values | | text.bytes() | [uint8]-shaped view | UTF-8 bytes | On native targets, string is immutable owned UTF-8 (the library's String literally holds bytes: Unique<[uint8]>); on JS/TS targets, strings are the host engine's strings. Intervals Ranges in type position become an interval type over bounded sets like int, bigint, or char; basically, an interval type is a static subset of its scalar type (e.g., 1..4 in type position is equivalent to 1 | 2 | 3). Assignments to interval-typed places must already have an interval-compatible type - the compiler does not prove arithmetic expressions stay inside intervals and we do not insert implicit runtime checks for interval assignments. type Digit = 0..=9; type LowerAscii = 'a'..='z'; type UserPort = 1024..=65535; let digit: Digit = 7; let letter: LowerAscii = 'm'; let port: UserPort = 8080; digit satisfies int; letter satisfies char; port satisfies int; Interval types also compose with unions, aliases, and newtypes: type HexDigit = 0..=9 | "a" | "b" | "c" | "d" | "e" | "f"; type NonZeroByte = 1..=255; newtype Port = 1..=65535; Intervals can also constrain static parameters: struct InlineBuffer { storage: [T; N]; } Because interval types are basically just aliases to union types, they must be bounded sets, and as floating point are not bounded, plain numbers cannot participate. It follows that runtime values can become an interval type through ordinary narrowing - good old range patterns and is checks: newtype Port = 1..=65535; function parsePort(n: int): Result { match (n) { 1..=65535 => Result.ok(Port(n)) // `n` is narrowed into the interval _ => Result.err(ParseError(`port out of range: ${n}`)) } } Newtypes TypeScript is (primarily) structurally typed: an interface is satisfied by any value matching its shape, regardless of whether it explicitly implements the interface. However, sometimes explicit nominality is helpful for correctness and expressiveness, and Destack adds newtype as the nominal counterpart to type. Like type, newtype follows its backing type: representable backings result in nominal concrete types, and constraint backings produce nominal constraints. For example, with plain types and aliases, there is no actual protection against accidental assignment. (The TS ecosystem commonly resorts to "branding" hacks to work around this limitation.) type UserId = number; 0 satisfies number; // OK, TS is happy, but ouch type OrderTag = string; "invalid" satisfies OrderTag; // OK, TS still happy, also ouch newtype UserId = number; const userId: UserId = 0; // ERROR: number is not UserId newtype OrderTag = string; const orderTag: OrderTag = "invalid"; // ERROR: string is not OrderTag To construct a concrete newtype value, use explicit T(..) call syntax: newtype UserId = number; UserId(1) satisfies UserId; newtype OrderTag = string; OrderTag("tag") satisfies OrderTag; newtype Point = (number, number); Point(1, 2) satisfies Point; newtype Rectangle = { start: Point; end: Point; } Rectangle({ start: Point(0, 0), end: Point(1, 1) }) satisfies Rectangle; newtype AuthenticatedUser = User; AuthenticatedUser(user) satisfies AuthenticatedUser; Concrete newtypes are representation-transparent to the compiler but opaque to the type system. Construction and projection across the backing boundary are explicit at the type level and of course "zero-cost" at runtime: const id = UserId(1); const raw = id as number; Newtype Interfaces Newtype declarations add nominality to their backing type, and Destack also supports nominal interfaces using the newtype modifier on interface declarations. (This makes newtype interfaces behave essentially like traits in other languages, no weird "branding tricks" required.) // structural interface (standard TypeScript behavior) interface Drawable { draw(): void; } const x: Drawable = { draw() {} }; // OK: structural match // nominal interface (requires explicit `implements`) newtype interface Add { type Output; add(other: T): this.Output; } Nominal interfaces require explicit implements clauses, so structural compatibility alone doesn't satisfy the constraint (unlike with regular interface). Newtype interfaces are used for explicit behavioral traits like operator interfaces (e.g., Add, Compare), and for capability traits (e.g., Send, Sync, Copy, and Clone). A newtype interface is nominal as a constraint, but it is still not a concrete value representation. Extensions It is sometimes convenient to attach additional logic and data directly to a type, even and especially when the type is not defined locally (in the same module or even the same package). Rust supports this with impl blocks (and only impl blocks, actually), and Destack supports additional extensions to add instance and static members to any nominal type: class Vector2 { x: float32; y: float32; constructor(x: float32, y: float32) { this.x = x; this.y = y; } } // extension may be in a different file or package altogether extension of Vector2 { static ZERO = new Vector2(0.0, 0.0); magnitude(): float32 { return (this.x * this.x + this.y * this.y).sqrt() } } Extensions can be added to any nominal type, including struct, class, enum, and newtype, whether defined locally or in a foreign / imported module. Plain type aliases (type X = ...) and structural types ({ x: number }) cannot receive extensions because it would be unclear when they should apply. Extensions can also be named for and then referenced explicitly for export and import: import { User } from "@/model/user"; export extension UserUtils of User { validate(): boolean { todo("..."); } } The visibility of extension members follows from their placement: - Same file as type: Extensions are automatically visible wherever the type is used. - Anonymous on foreign type: Only visible in the file where declared (extension of int32 { ... }). - Named on foreign type: Must be explicitly imported to use (export extension DateUtils of Date { ... }). Blankets Extensions may declare their own generic parameters to extend generic types, and a generic extension over a whole family of instantiations is a "blanket" extension (once again, similar to Rust): extension of Box { isEmpty(): boolean { // ... } } // conditional conformance: Box is Show only when T is too extension of Box implements Show where T: Show { show(): string { `Box(${this.value.show()})` } } The target of a blanket decides the scope of the claims it may make: | Blanket | Example | Who may declare it | | --- | --- | --- | | Members over a bounded parameter | extension Arithmetic of T { ... } | anyone | | implements over a nominal application | extension of Box implements Show | anyone | | implements over a bare bounded parameter | extension of T implements PartialEqual | only the package declaring the interface | Member blankets are lexical like all extension members, so the bound just names the candidate domain and nothing can surprise code that didn't import it. (Unbounded targets - including the T: unknown spelling of the same thing - are rejected, because methods on everything pollute every candidate set.) An implements blanket makes a global claim, so it needs a clear owner: a nominal target gives the conflict surface one, while an open-domain claim over every type that ever satisfies a bound belongs to the contract's owner. That last form is also how interface hierarchies ship their bridges anyway: // in the package declaring PartialEqual extension of T implements PartialEqual { // ... } All implements blankets participate in the same general coherence rules: overlapping implementations of the same interface for the same type - including blanket-vs-specific overlap - are a program-wide error (see Coherence). Enums Enums are nominal aliases to a set of constants, just like in TypeScript, but in Destack, enums do not implicitly cast to their backing type and explicit conversions are required for the backing value type. Like other nominal types, enums can carry instance and static members, and of course can also receive extensions. enum Priority { Low = 1, Medium = 2, High = 3, static Default = Priority.Medium; label(): string { match (this) { Low => "low" Medium => "medium" High => "high" } } } Tagged Unions Discriminated unions are very convenient and fit well into existing TypeScript, but by themselves lack nominal containers (and items) to attach behavior to. Using Destack's nominality via newtype and the builtin Tagged derive, TypeScript's well known discriminated unions become even more ergonomic sum types: @derive(Tagged) newtype Shape = | { kind: "rectangle"; width: int32; height: int32 } | { kind: "circle"; radius: int32 }; extension of Shape { static DEFAULT = Shape.Rectangle({ width: 10, height: 20 }); variant() { match (this) { Shape.Rectangle({ width, height }) => "rectangle" Shape.Circle({ radius }) => "circle" } } } // create values of tagged newtype unions with . const rectangle = Shape.Rectangle({ width: 10, height: 20 }); const circle = Shape.Circle({ radius: 5 }); The discriminant field is inferred from the union via the Tagged derive macro from the unique common field whose variants carry distinct literal values. It behaves essentially just like builtin sugar that is expanded into a constructor function: extension of Shape { // for each tagged variant, expose a "constructor" by tag static Rectangle({ width: int32, height: int32 }) { { kind: "rectangle", width, height } } // ... } Except that Tagged enums also work in pattern position, and - thanks to some compiler magic that wouldn't work in pure userland - the variant head behaves like a real variant pattern: match (shape) { Shape.Rectangle({ width, height }) => width * height Shape.Circle({ radius }) => radius * radius } By default, string discriminants are exposed as UpperCamelCase constructor names - the other supported naming policies are: | Tagged Casing | Example | |--------|---------| | "preserve" | rectangle → rectangle | | "camelCase" | rectangle_shape → rectangleShape | | "UpperCamelCase" | rectangle_shape → RectangleShape | | "snake_case" | RectangleShape → rectangle_shape | | "SCREAMING_SNAKE_CASE" | RectangleShape → RECTANGLE_SHAPE | @derive(Tagged({ case: "preserve" })) newtype Shape = | { kind: "rectangle"; width: int32; height: int32 } | { kind: "circle"; radius: int32 }; const rectangle = Shape.rectangle( /* ... */ ); const circle = Shape.circle( /* ... */ ); Structs Structs are nominal value types for data with a fixed shape, but without reference identity, constructors, or inheritance. Basically, structs are just their values with a name attached, much like structs in other "systems-y" managed languages. Structs are created via the usual T { .. } constructor form to distinguish them from regular objects, they do not and cannot have new-like constructors of their own. struct Point { x: float32; y: float32; } let x: Point = Point { x, y }; // OK let x: Point = { x, y }; // ERROR: plain object is not Point Like all type positions, struct expressions also support the _ placeholder for contextual type inference: let x: Point = _ { x, y }; // OK Struct expressions can update an existing struct value with the familiar "spread" operator ...expr, keeping the nominal struct type: const moved = Point { ...x, y: 10.0 }; moved satisfies Point; Plain object spreads can also read the fields of a struct value, but they produce a structural object rather than the nominal struct. const object = { ...x, label: "origin" }; // x: Point object satisfies { x: float32; y: float32; label: string }; Conversely, struct expressions can also spread from object literal expressions (when the field set satisfies the struct): const base = { x: 1.0, y: 2.0 }; const point: Point = _ { ...base }; Classes can also be spread into plain object expressions, following TypeScript's object-shaped model, but the result is structural and carries only fields. Classes Classes follow the TypeScript-shaped model for managed objects with identity - except, of course, without the prototype chain or any dynamic or unsound shenanigans. Also, class fields require every instance field to be initialized by its declaration or every constructor path. (Optional fields do not need eager initialization, they default to undefined.) class Counter { value: int32; constructor(value: int32) { this.value = value; } increment(): int32 { this.value += 1; this.value } } const counter: Counter = new Counter(1); counter.increment() satisfies int32; Like structs, classes support the _ placeholder for type inference: const counter: Counter = new _(1); counter.increment() satisfies int32; Class methods are concrete (have only one implementation) by default and must be declared as virtual to enable virtual dispatch of instances methods in subclasses. Similarly, class methods are marked as abstract to require an override in an implementing subclass such that the class can actually be constructed. abstract class Logger { abstract write(message: string): void; virtual flush(): void {} } Additionally, classes may be marked final to prevent downstream classes from extending the declaration. final class PacketHeader { length: uint32 = 0; } TypeScript visibility modifiers are fully supported, but the #field private syntax form is redundant and not allowed in .ds files (use private instead). Arrays, Slices and Tuples Destack supports richer sequence forms beyond TypeScript's dynamic arrays - T[] / Array with explicit slices, fixed arrays, and tuples. Unfortunately, not much syntax was left here, so we had to adopt the slightly non-TS-y syntax forms of [T] and [T; N] for slices and fixed arrays, respectively. | Forms | Representation | Meaning | |------|----------------|---------| | T[], Array | Collection class | Growable, homogeneous, dense sequence with capacity | | [T], Slice | Slice header | Pointer plus length, no capacity | | [T; N], FixedArray | Inline array | Exactly N elements stored in the value | | (A, B) | Inline product | Heterogeneous sequence of owned values | Dynamic arrays are just class, regular managed objects with identity, while slices, fixed arrays, and tuples are value types (structs, basically). However, Destack does not permit holes in arrays or any other sequences, and indexing into T[] therefore always returns T. Unlike in Rust, and somewhat more like in Go, Destack's [T] slice is a sized fat pointer and thus a first-class slice value. let x: int32[] = [1, 2, 3]; // dynamic array of int32 let x: Array = x; // dynamic array of int32 let x: [int32] = [1, 2, 3]; // slice of int32 let x: Slice = x; // slice of int32 let x: [int32; 3] = [1, 2, 3]; // fixed array of int32 let x: FixedArray = x; // fixed array of int32 let x: (int32, int32, int32) = (1, 2, 3); // tuple of int32 By default, array literals are dynamic arrays but can coerce to other sequence forms in place when contextually required. Fixed arrays are just a homogeneous sequence of values whose length is statically known (and part of the type): they are inline value/layout types by default, and definitionally cannot grow. (If you need an array that can grow, use a dynamic array, i.e. T[] / Array) type Block = [uint8; 4096]; // 4KB of uint8 type Vec3 = [float32; 3]; // 3 float32s let rgb: [uint8; 3] = [255, 128, 0]; let zeroes: [uint8; 32] = [0; 32]; Fixed arrays and slices also work directly in patterns: fixed array patterns already know their length statically, while slice patterns can use a rest binding for the tail / head: declare const rgb: [uint8; 3]; declare const bytes: [uint8]; let [r, g, b] = rgb; match (bytes) { [0x89, 0x50, 0x4e, 0x47, ...rest] => parsePng(rest) [0xff, 0xd8, ...rest] => parseJpeg(rest) _ => Result.err("unknown image format") } Tuples are fixed heterogeneous products, and of course also work as patterns: const point: (int32, int32) = (1, 2); const (x, _) = getPoint(); One-element tuples use a trailing comma, empty tuples are just (). Since tuples are also just value containers, empty tuples occupy no space. type One = (int32,); const one: One = (1,); const empty: () = (); Readonly TypeScript's readonly is shallow, while in .ds, readonly T is always a deep read-only view of T. That is, readonly T forbids any mutation through its T, and readonly T cannot be assigned to T, including via nested members. struct Profile { name: string; } struct User { profile: Profile; tags: string[]; } declare const user: readonly User; user.profile.name = "Grace"; // ERROR: readonly view user.tags[0] = "admin"; // ERROR: readonly view Generics Destack supports classic TypeScript-shaped generics: inference, constraints, defaults, conditional types, mapped types, indexed access types, and the rest of the usual machinery. Unlike in TypeScript, however, Destack's parameters can also represent values that are then substituted into expressions and both values and types are available during inference. To distinguish static value parameters from static type parameters (and literal value types), we use the comptime modifier on the generic parameter declaration (akin to Rust's const modifier, alas this was already taken in TypeScript): type Buffer = [uint8; N]; Generic bounds can use the cleaner form, just like dynamic parameters. Unlike with comptime (discussed later), the comptime modifier merely means that N is a generic value parameter that has to be evaluatable as a static term during inference. function copy(src: [T; N]): [T; N] { let dst: [T; N]; for (let i = 0; i < N; i++) { dst[i] = src[i]; } dst } As in TypeScript, type parameters may include a const modifier to retain "fresh" precision in arguments. To be clear, const T is about type widening, and const T parameters are still type parameters (unlike comptime N), and they're also the reason we had to use a different syntax for true value parameters in the first place. declare function freeze(value: T): T; const value = freeze({ kind: "ready", level: 1 }); value.kind satisfies "ready"; Dynamic parameters may also be marked comptime when the caller should pass an ordinary argument expression that is still required to be evaluatable as a static term during compile time, mostly as a readability affordance where spelling the value as a generic argument would be awkward or constraining. (It also means we can progressively make an argument statically known, without forcing a generic signature, which is nice and ergonomic in some situations.) function repeat(value: T, comptime count: uint): [T; count] { // ... } const values = repeat("x", 3); values satisfies [string; 3]; Like dynamic parameters, Destack's generic parameters also support ... forms: type Callback<...Parameters, Return> = (...parameters: Parameters) => Return; Type inference (including generics) works across modules, even when modules circularly reference one another - though of course, this should be used with caution and can lead to longer compile times because it forces large connected components during inference checking. Variance Variance describes how typing and subtyping relations work for generic types, including for all the types that managed language users may not even usually think of as generic (like Array). Mutable covariance - the fact that we can assign Circle[] to Shape[] and then mutate Circle[] through the widened Shape[] alias - is one of TypeScript's best known soundness holes and a classic footgun. Because Destack needs to be actually sound, we only support this sort of widening when it is unambiguously safe: | Position | Variance | Example | | --- | --- | --- | | Readonly positions | covariant | readonly Circle[] is assignable to readonly Shape[] | | Mutable storage positions | invariant | Circle[] is not assignable to Shape[] | | Function parameters | contravariant | (shape: Shape) => void is assignable to (circle: Circle) => void | | Function returns | covariant | () => Circle is assignable to () => Shape | For generic types, variance is derived per parameter from each generic parameter's usage in the declaration (like in TypeScript): a parameter that only comes "out" (returns, readable fields) is covariant, one that only goes "in" (parameters, writable fields) is contravariant, and one that does both - a mutable field counts as both at once - is invariant. class Source { take(): T } // T only comes out -> covariant class Sink { put(value: T): void } // T only goes in -> contravariant class Pipe { take(): T; put(value: T): void } // both -> invariant And because Array is just a regular (well known) standard library type, and it has both readable and writable positions for its generic parameter, Array is invariant in T: declare const circles: Circle[]; const shapes: Shape[] = circles; // ERROR: mutable arrays are invariant const view: readonly Shape[] = circles; // OK: readonly views are covariant const copies: Shape[] = [...circles]; // OK: explicit copy reifies Shape elements For the other spellings of "a collection of shapes", the element representation decides everything: | Element type | Shape[] means | Holds | | --- | --- | --- | | class Shape | array of managed references | any subclass, open set | | type / newtype union | array of tagged variant layouts | the listed variants, closed set | | interface Shape | induced generic, Array | one concrete T, homogeneous | | Dynamic | array of erased fat pointers | any implementor, open set | Static Unlike TypeScript, Destack actually compiles, so we need to figure out during "compile time" the final type of each value and fill in values for all the known constants. To do this, we need to consider three different "evaluation times" between the source code and the actual executable: | World | Meaning | Example | |-------|---------|---------| | Static | types, values, and relations known while checking | T, N, this.Width, T extends string? A : B | | Comptime | ordinary code explicitly evaluated by the compiler | comptime factorial(10) | | Runtime | ordinary program execution | readFile(path), worker.postMessage(msg) | Type inference and static term evaluation are one and the same, which is why we get both fast generics and powerful type evaluation using static terms: a small subset of the language (like TypeScript type operators) available during type inference. All dynamic comptime execution happens after type inference, so it can do anything that runtime code can do. This keeps compilation fast and predictable, and thanks to TypeScript's flexible type algebra, static terms are still pretty powerful: | Input | Example | |-------|---------| | Type parameters | T | | Static value parameters | N in function f() | | Type aliases and generic applications | Buffer, Payload | | Associated types and constants | I.Item, Register.Width | | Static consts and imports | N imported from ./a.ds | | Enum members and nominal constants | OperatingSystem.Windows | | Literal values | 4, "shared", true | | Static operators | N * 2, Mode == "inline" | | Contextual type form | PlaceOf inside a type declaration | | Module and profile metadata | import.meta.platform | | Type operators | keyof T, T[K], T extends string, T implements I | | Layout intrinsics | sizeOf(), alignOf() | Static terms are required wherever the language needs an inference-known answer: fixed array lengths, conditional types, associated members, static decorators, layout queries, and placement algebra. Type inference may flow out of modules, but Destack does not support circular static inference or inference across modules in any way. type Block = [uint8; N]; type Payload = T extends string ? Utf8Payload : BinaryPayload; type BufferIndex = Mode == "inline" ? InlineIndex : ExternalIndex; struct Buffer { index: BufferIndex; data: T[]; } In the Buffer example, Mode is carried as a generic value and used by a conditional type. The declaration still has one checked field named index; only the field type changes by instantiation. Generic-dependent static terms may select types, constants, layouts, and overloads, but they do not remove source nodes from a generic declaration. That keeps generic declarations checked once, Rust-style, instead of turning generic instantiation into template expansion. function size(): Wide extends true ? 8 : 4 { if (comptime Wide) { return 8; } else { return 4; } } Associated Types and Constants Associated types and constants contribute static members to a type that can be reused within the type and its implementors but do not need to be exposed to every single caller. Both associated types and constants also work in abstract types, and as they are associated with the type directly, they do not occupy any instance space on the type. All statically known types and constants share the same static evaluation logic, and thus associated types and constants also mix with generic parameters, conditional types, decorators, and so on. newtype interface Collection { type Item; type Return = void; next(): IteratorResult; } function collect(iter: I): I.Item[] {} Associated types are type aliases scoped to some struct, class, or interface and can also reference the owner's generic parameters. interface BufferPool { type Buffer; type Error; acquire(count: usize): Result, this.Error>; release(buffer: this.Buffer): void; } Associated types can have their own generic parameters with the same generic parameter forms as ordinary declarations, including type parameters and comptime value parameters (these are generic associated types, often called GATs). interface Storage { type Handle; type Page; } struct SharedStorage implements Storage { type Handle = shared StorageHandle; type Page = shared StoragePage; } In addition to associated types, nominal type declarations also support associated constant members as static compile-time values. Like static members, comptime consts require no instance storage, but unlike static members, comptime consts are statically evaluated during compilation. interface RegisterBlock { comptime const Width: uint; read(): [uint8; this.Width]; write(bytes: &[uint8; this.Width]): void; } Associated members participate in the same static evaluation / inference world, and so associated members can express dependent types and values that are dependent on others (including inferred!). interface Matrix { comptime const Width: uint = Row extends string ? 8 : 4; type Bytes = [uint8; this.Width]; } Associated members (both types and constants) can be refined explicitly at application sites whenever an erased or constrained value needs a concrete associated surface with type Name = T for types and comptime Name = value for constants. declare function readBlock>(block: T): [uint8; 16]; Constraints Sometimes, defining the constraints and relations for type parameters can become unwieldy or outright impossible with only type annotations on each individual term. Destack supports explicit (type-space) where clauses to define additional constraints for complex types and signatures, very much like Rust: function merge(): T where U: Comparable { // ... } A where clause accepts the same constraint forms as inline bounds, plus a few relations that inline bounds cannot otherwise express: | Form | Example | Meaning | | --- | --- | --- | | Interface bound | T: Comparable | a parameter must satisfy a constraint | | Associated member bound | I.Item: Display | an associated type must satisfy a constraint | | Equality constraint | T.Output == U | two static terms must normalize to the same type or value | Shapes Because Destack inherits TypeScript's type forms of class, type and interface, and then adds struct value types and nominality via newtype, we now have a spectrum of six different ways of spelling that something looks like a Point { x: number; y: number }. Bleh. It is what it is. A little unfortunate, but we couldn't figure out a good way to compress these shapes without losing either key additions like nominality and value types or compatibility guarantees like type, interface, and class. So, here goes: | Form | Role | Representation | | --- | --- | --- | | type Point = { x: number; y: number } | Transparent alias for a type expression. | Transparent | | interface Point { x: number; y: number } | Named structural constraint with extension syntax. | Transparent | | newtype interface Point { x: number; y: number } | Nominal interface (trait). | Transparent | | newtype Point = { x: number; y: number } | Nominal wrapper over an (object) shape. | Managed object | | struct Point { x: number; y: number } | Nominal value product. | Owned value | | class Point { x: number; y: number } | Nominal identity object. | Managed object | Hopefully, these mostly behave as expected, even if the assortment is bigger than what one would usually get. They do all actually fill slightly different niches, and, fortunately, they compose quite well, and let us think in terms of types, transparency, nominality, and layout as needed, which is quite neat. Representation Destack's general philosophy is to let users opt in to additional control and complexity as needed - as much as possible, things should "just work" like in TypeScript. That said, the actual memory representation of types does matter, and our unique blend of TypeScript type algebra and actual systems-y AOT compilation means that Destack needs to make representation tradeoffs differently from other languages. Specifically, we distinguish three main axes of type behavior that affect representation: | Axis | Question | Examples | | --- | --- | --- | | nominal vs structural | must the type be constructed or implemented explicitly? | newtype / struct / class / newtype interface vs type / { x: number } | | transparent vs opaque | does the name just expand to a type expression? | type vs interface / struct / class / newtype | | concrete vs abstract | is there already a representation we can store directly (the builtin Concrete bound)? | primitives / struct / class vs bare constraints | The position decides who commits to a representation: a constraint position leaves the choice to each use site, while a storage position makes the declaration commit. All storage positions induce an implicit Concrete requirement on the value being stored, and Concrete is also available explicitly for the few cases where that's helpful (e.g. type InlineBytes = [uint8; sizeOf()]). Wherever a type can have multiple possible runtime representations, Destack induces an implicit generic parameter and monomorphizes all applications of that parameter, just like with an explicit generic type: type Point = { x: int32; y: int32; }; function draw(point: Point): void { // ... } struct Rectangle { position: Point; } draw({ x: 1, y: 2, z: 3 }); // OK const rectangle = Rectangle { position: { x: 1, y: 2, z: 3 } // ERROR: stored `Point` has exact layout }; Transparent types - type aliases - induce a generic in constraint positions (like a function parameter) but are encoded directly in storage positions (like a field in an aggregate). This is primarily such that the common pattern of class Player { status: "running" | "walking" | "idle" } (which is really just a type alias) works as expected without any additional confusing generics. Unlike transparent type aliases, interfaces also induce a generic in storage positions (even as both behave like structural types in general): interface PointLike { x: int32; y: int32; } function draw(point: PointLike): void { // ... } struct Rectangle { start: PointLike; end: PointLike; } Desugared, that is roughly: struct Rectangle { start: TStart; end: TEnd; } Anonymous constraint expressions require a specific type to be filled in at usage sites and thus induce a generic: newtype interface Writer { write(bytes: [uint8]): Result; } interface Writer { write(bytes: [uint8]): Result; } type Writer = { write(bytes: [uint8]): Result; } For contrast, a function-valued field is still an ordinary representable structural shape, because the field itself has a representation (a function pointer): type WriterField = { write: (bytes: &[uint8]) => Result; }; Structural type annotations still typecheck structurally, and structural value expressions synthesize concrete anonymous shapes when they are used as values. Here the annotation checks the object shape, and the object expression supplies the concrete value shape: const point: { x: int32; y: int32 } = { x: 1, y: 2 }; Return positions are also considered storage positions, and the return representation is solved from the function body: - When all joined return paths produce one concrete type, the return is existential, that is, callers type against the declared transparent type, but the compiled function returns the specific concrete representation at zero cost. - When the paths join different concrete types and the declared return is a transparent data shape (like a union alias), the return reifies that shape's concrete layout - for a union alias, the tagged variant layout - just as a stored field would. type Shape = Rectangle | Circle; // transparent alias function foo(): Shape { return Rectangle(); // existential: the compiled return type is Rectangle } function bar(): Shape { // reified: the compiled return type is the Shape variant layout if (getRandom() > 4) { return Circle(); } else { return Rectangle(); } } A representation change is also exactly what separates a compiled conversion from a plain type-level widening. An implicit conversion reifies as a coercion only when the identity function is not a valid witness for it: injecting a value into a tagged union writes a tag and erasing behind Dynamic builds the fat pointer, while readonly and variance widenings change nothing physical and compile to nothing. Literals never convert at all; a constant materializes directly at its solved type. Dynamic The default being that structural constraints become hidden generic parameters is generally great for performance in a type-heavy language like TypeScript, and it works especially well because we always compile statically from source. However, sometimes explicit runtime indirection / erasure is desired, and Destack also provides an intrinsic Dynamic wrapper as the explicit erased runtime value satisfying any dynamic-safe T: // just like the function, this Logger is implicitly generic over Writer struct Logger { writer: Writer; } // the Logger above is the same as Logger struct Logger { writer: T; } // for fixed layout, erase the constraint struct LoggerFor { writer: Dynamic; } For a type T to become concrete (as required by Dynamic), it must have a shape we can actually erase into a value at runtime (the "runtime witness": we call this property DynamicSafe. Basically, the T in Dynamic implies DynamicSafe, which is very similar to Rust's "object-safe" requirements for dyn T: - no generic members that introduce new generic parameters - no index signatures - no unqualified reference to this Of course, the Dynamic value itself is always Concrete: erasure is precisely what gives an abstract constraint one fixed sized runtime representation. Layout Layout is the concrete storage and ABI shape selected for a representable type under the active target, the default representation being @repr("destack"). Only represented types have layout; structural shapes become represented when a representation slot reifies them, while incomplete constraints must be preserved through a generic parameter or erased behind Dynamic. The exact layout of a type can be configured via decorators that constrain its representation as needed, the conventions being very similar to Rust's: | Decorator | Meaning | | --- | --- | | @repr("destack") | Use the native Destack representation. | | @repr("C") | Use the active target's C ABI layout. | | @repr("transparent") | Give a single-field declaration the same ABI representation as its field. | | @repr(T) | Use primitive scalar T as an enum backing representation. | | @repr({ align: N }) | Raise the minimum aggregate alignment to N. | | @repr({ packed: true }) / @repr({ packed: N }) | Lower the maximum field alignment, with true equivalent to 1. | @repr({ align: 64 }) struct CacheLine { value: uint64; } @repr("C", { packed: true }) struct WireHeader { tag: uint8; size: uint32; } Destack also supports querying parameters of the effective representation during compilation - available as a static term during inference - for conditional branching and storage: | Layout Query | Result | | --- | --- | | sizeOf() | The byte size of T as usize. | | alignOf() | The required alignment of T as usize. | | strideOf() | The spacing between adjacent array elements of T as usize. | | layoutOf() | The reflected size, align, stride, and shape for T as a Layout value. | Layout queries evaluate as type operations: a query over an open or generic type stays symbolic and reduces to its value once its argument becomes concrete. Inference therefore answers exactly the queries whose inputs it has settled, and comptime evaluation forces the remainder. Layout is never stored in the compiler's program representation; every phase recomputes it on demand from the type and the active target through one shared measure, and code generation derives its structural layout from that same measure. Reflection TypeScript types are - by design - erased at runtime, which means we can't easily perform runtime type checks or any meaningful reflection. Destack supports type reflection both at runtime and at compile time with Type as a normalized view. Dynamic type expression can be turned into its reflected type with (implicit or explicit) casting to its Type representation: struct User { name: string; age: uint; } let user: User = User { name: "Alice", age: 30 }; const UserType: Type = User; // implicit cast const UserType = Type.of(); // explicit, same thing This also works for generic APIs that operate on types as static values: function parse(raw: string): T { } const user = parse("..."); const userSize = comptime sizeOf(); const requestLayout = comptime layoutOf>(); type InlineBytes = [uint8; sizeOf()];