Comparison
It's generally helpful to learn a new thing in contrast to things we already know, and in that spirit we have compiled a short comparison overview of what Destack looks like coming from each of these languages. The Destack language is deliberately trying to be "TypeScript++", so that's where we begin, but there are many similarities and some differences to other well known languages - most notably Rust - that bear pointing out.
TypeScript
Destack is a superset of the "modern strict" subset of TypeScript. The intended use case for Destack is making TS-shaped code correct and optimal, which requires both cutting all accumulated dynamic magic and introducing systems-y features for memory control. Accordingly, Destack excludes legacy syntax and all sorts of dynamic shapes and protocols that are not statically sound, and also removes a few rarely used footguns.
It's important to note that Destack deliberately has no JavaScript or NPM interoperability: .ds code cannot import or call arbitrary JS, and Destack packages cannot depend on npm packages in any way.
The main reason for this is that to take full advantage of Destack's features and full integration across the stack, we need to essentially rewrite all libraries anyway.
In general, most strict modern TypeScript needs no changes at all:
1interface User {2 name: string;3 age: number;4}5 6function adults(users: User[]): User[] {7 return users.filter((user) => user.age >= 18);8}9 10const names = adults([{ name: "Ada", age: 36 }]).map((user) => user.name);Modules
Destack supports only ESM syntax: one static module graph, nothing mutable at runtime.
| Feature | Example | Ruling |
|---|---|---|
| Type-only imports / exports | import type { User } from "./user" |
supported as plain import / export aliases |
| CommonJS | require("x"), module.exports |
not supported, a legacy mutable runtime module system |
| Namespace declarations | namespace Name { ... } |
not supported, use real modules |
| String module declarations | declare module "pkg" { ... } |
not supported, declare real modules instead |
| Declaration merging | two interface User blocks |
not supported, one declaration per name, extensions cover augmentation |
| Import type queries | import("pkg").User |
not supported, use ordinary static imports |
| Dynamic module loading | import(expr) |
not source-level loading, though JS output may still chunk |
| Import defer | import defer * as ns from "pkg" |
not supported |
| Import assertions | assert { type: "json" } |
not supported, use standardized with { ... } attributes |
| String export names | export { value as "name" } |
not supported |
| Circular inference | mutually inferred module exports | not supported across modules |
| Flow and JSDoc typing | /** @type {Foo} */ |
ignored, or rejected when not valid TS/TS++ |
Values
Bindings are strict-mode const and let with definite assignment, and the legacy spellings are gone.
| Feature | Example | Ruling |
|---|---|---|
| Var declarations | var x |
not supported, var scoping is unnecessary with const and let |
| Destructuring | const { name } = user |
supported in declarations, assignments, parameters, catch bindings, and loops |
Shadowable undefined |
let undefined = value |
not supported, undefined is a literal keyword just like null |
| Sequence expressions | (a, b, c) |
not supported, parenthesized comma lists are explicit tuples in .ds |
| Definite assignment assertions | let x!: T, field!: T |
rejected in .ds, locals and fields must be initialized before use |
| Sloppy mode | duplicate declarations, with |
not supported, Destack targets strict mode |
Callable Symbol |
Symbol("name") |
not supported, use Symbol.create("name") |
Primitives
The TypeScript primitives keep their meaning, but we support more scalar types.
| Feature | Example | Ruling |
|---|---|---|
number |
let x: number |
stays the default numeric type, an alias for float64 |
| Integer and float widths | int32, uint8, float32, ... |
added as real scalar types |
| Single-quoted literals | 'A' |
always a char; strings use double quotes |
| String indexing | text[0] |
yields char in .ds, and traps on a lone surrogate |
symbol |
let key: symbol |
supported as a regular property key type |
unique symbol |
const key: unique symbol |
supported for statically known singleton keys |
Characters
Single quotes are scalars, double quotes are strings:
1const initial = 'A'; // char, not a one-character string2 3const text = "héllo";4 5text.length satisfies uint; // UTF-16 code units, as in JS6text[1] satisfies char;Arithmetic
Integer arithmetic traps on overflow in all build configurations, and as never loses information.
| Feature | Example | Ruling |
|---|---|---|
| Overflow | int32 max + 1 |
traps in every build mode |
| Wrapping | (a + b) & 0xff idioms |
explicit: wrappingAdd, Wrapping<T> |
Numeric as |
big as int8 |
lossless conversions only, lossy ones are explicit methods |
Conversions
Lossy numeric conversion requires an explicit method call:
1const big: int32 = 100_000;2 3const bad = big as int8; // ERROR: lossy conversion4big.truncate<int8>(); // keep the low bits5big.saturate<int8>(); // clamp to bounds6big.tryInto<int8>(); // checkedUnknown
unknown stays; any dies.
| Feature | Example | Ruling |
|---|---|---|
any |
let x: any |
forbidden in all sources, an unchecked escape hatch in both directions |
unknown |
declare const data: unknown |
supported, and induces a generic in constraint positions |
Any and Unknown
unknown does the safe half of any's job:
1declare const input: any; // ERROR: `any` is forbidden2 3declare const data: unknown;4 5if (data is string) {6 data satisfies string;7}Shapes
Object shapes are static and exact: no prototype tricks, no runtime mutation, and fresh literals are checked everywhere.
| Feature | Example | Ruling |
|---|---|---|
Interchangeable type / interface |
data-shaped interface Point in a field |
diverges in storage positions |
Record<K, V> |
Record<string, User> |
closed utility type, use Map<K, V> for dynamic keyed storage |
object |
let value: object |
not supported, use a structural shape, unknown, or an interface |
| Declaration expressions | const C = class {} |
not supported, runtime type generation is not statically knowable |
| Prototype objects | .prototype, .__proto__, Object.setPrototypeOf |
not supported |
| Shape mutation | delete obj.x, Object.defineProperty |
forbidden, object shapes must stay statically known |
| Metaobject dispatch | Proxy, most Reflect.* APIs |
not supported |
| Array holes | [1,,3] |
not supported, sequences are dense |
| Excess properties | fresh literal with extra fields | rejected at every boundary, not just some |
Type vs Interface
In TypeScript, type and interface are mostly interchangeable.
In Destack they diverge in storage positions: aliases are exact data shapes, interfaces are constraints and induce hidden generics when stored.
1type Point = { x: number; y: number };2 3struct Rectangle {4 start: Point; // exact storage, as in TypeScript5}6 7interface PointLike {8 x: number;9 y: number;10}11 12struct Sprite {13 position: PointLike; // induces a hidden generic: Sprite<T: PointLike>14}Freshness
The classic typo stays caught, at every boundary:
1interface SquareConfig {2 color?: string;3 width?: number;4}5 6declare function createSquare(config: SquareConfig): void;7 8createSquare({ colour: "red", width: 100 }); // ERROR: excess property `colour`Classes
Classes keep their TypeScript surface but become properly nominal.
| Feature | Example | Ruling |
|---|---|---|
| Structural classes | same-shaped classes interchangeable | classes are nominal, structure does not substitute for declarations |
| Private fields | #field |
not supported, redundant with real private in .ds |
| Parameter properties | constructor(private name: string) |
not supported, declare fields and assignments explicitly |
| Class index signatures | class C { [key: string]: T } |
not supported, classes have fixed declared members |
Nominality
Two classes with the same shape are still two classes:
1class Point2 {2 x = 0;3 y = 0;4}5 6class Point3 {7 x = 0;8 y = 0;9 z = 0;10}11 12const point: Point2 = new Point3(); // ERROR (OK in TypeScript)13const plain: Point2 = { x: 0, y: 0 }; // ERROR (also OK in TypeScript!)TypeScript classes only turn nominal once they have a private member, which is why the branding hack works; Destack classes are nominal always.
Enums
Enum fields are nominal constants, without TypeScript's number leakage.
| Feature | Example | Ruling |
|---|---|---|
| Enum coercion | Level.A as number |
no implicit coercion, enum fields are nominal constants |
Generics
Generics work as in TypeScript, with explicitness required where inference would have to guess.
| Feature | Example | Ruling |
|---|---|---|
| Declaration parameter inference | function f(x = 1) {} |
not supported, public declaration surfaces need explicit parameter types |
| Bodyless concrete overloads | function f(x: string); |
not supported outside declaration contexts, just write multiple bodies |
Variance
TypeScript's best known soundness hole is closed: mutable positions are invariant.
| Feature | Example | Ruling |
|---|---|---|
| Mutable covariance | Circle[] as Shape[] |
not supported, mutable generic positions are invariant |
Arrays
Readonly views keep the safe half of array covariance:
1class Shape {}2class Circle extends Shape {}3 4declare const circles: Circle[];5 6const shapes: Shape[] = circles; // ERROR: writing a Square through `shapes` would corrupt `circles`7const view: readonly Shape[] = circles; // OK: readonly views are covariantGuards
Flow narrowing works as in TypeScript; only the guards themselves change.
| Feature | Example | Ruling |
|---|---|---|
| Truthiness | if (value) |
boolean values only, control flow needs explicit tests |
Runtime typeof narrowing |
typeof x === "string" |
not supported, use is or instanceof |
| Type queries | typeof value |
supported in type position only |
| Type predicate / assertion signatures | value is T, asserts value is T |
not supported, callable type guards claim refinements that cannot be checked |
Truthiness
if takes booleans, not values:
1function printAll(values: string | string[] | null): void {2 if (values) {3 // ERROR: condition must be boolean4 }5 6 if (values !== null) {7 // OK: same narrowing, explicit test8 }9}Is
is replaces both runtime typeof tests and predicate signatures, and the compiler checks it instead of trusting it:
1function padLeft(padding: number | string, input: string): string {2 if (padding is number) {3 return " ".repeat(padding) + input;4 }5 6 return padding + input;7}Operators
Operators keep their JavaScript meaning wherever JavaScript has one, and stop existing where it does not.
| Feature | Example | Ruling |
|---|---|---|
| Loose equality coercion | a == b on objects |
no object coercion |
=== on value types |
pointA === pointB |
rejected for structs, tuples, and fixed arrays, use Equal |
| Coercion hooks | valueOf, Symbol.toPrimitive |
not used for implicit coercion |
| Symbol magic | Symbol.hasInstance, Symbol.species |
not supported, use typed protocols |
| Non-null assertions | value! |
supported as Try / must unwrapping, an explicit runtime operation |
| Type angle assertions | <T>value |
not supported, use value as T or value satisfies T |
Equality
Value types compare with Equal, not identity:
1struct Point {2 x: int32;3 y: int32;4}5 6Point { x: 1, y: 2 } === Point { x: 1, y: 2 }; // ERROR: `===` has no JS meaning for value typesClosures
Callable context is explicit: no ambient arguments, no dynamic this rebinding.
| Feature | Example | Ruling |
|---|---|---|
| Ambient call metadata | arguments, new.target, dynamic this |
not supported as magic bindings, callable context is explicit |
| Ambiguous generic arrow | <T>() => value |
not supported, ambiguous with TSX, use <T,>() => value |
| Dynamic constructors | new (factory())() |
not supported, construct values with type syntax like new Widget<T>() |
Loops
Every loop form works unchanged; only the manual iteration protocol is replaced.
(e.g., switch still has TypeScript fallthrough semantics)
| Feature | Example | Ruling |
|---|---|---|
| Manual iterator protocol | iter.next().done |
not supported, iteration is the nominal Iterator protocol, while for-of and spread lower unchanged |
Destructuring in for-of |
for (const { name } of users) |
supported |
for-in |
for (const key in object) |
supported for object-shaped receivers and yields string keys |
Trees
TSX works as tree syntax, minus XML namespace semantics.
| Feature | Example | Ruling |
|---|---|---|
| XML namespace resolution | <svg:path /> |
no xmlns binding semantics, namespaced tags are intrinsic string tag names |
Errors
Failures are Result values: throw is gone, and a Promise never rejects.
| Feature | Example | Ruling |
|---|---|---|
| Exceptions | executing throw |
not supported in native Destack code |
try / catch / finally |
try { ... } catch (e) { ... } |
works, as sugar over Result control flow |
| Catch destructuring | catch ({ message }) |
supported when the pattern is irrefutable for the failure value |
| Promise rejection | .catch, Promise.reject |
gone, a Promise<T> always fulfills |
| Rejection-shaped APIs | Promise.any, allSettled, two-arg then |
gone with rejection |
| Floating promises | bare refresh(); statement |
denied by default, await it, return it, or hand it to a scope |
| Thenables | await customThenable |
not supported, await works on the well known Promise<T> only |
Exceptions
The consuming side still reads like TypeScript:
1newtype ParseError = string;2 3declare function parsePort(raw: string): Result<uint16, ParseError>;4 5try {6 const port = parsePort(input)?;7 connect(port);8} catch (error: ParseError) {9 report(error);10}Promises
Async failure travels as AsyncResult<T, E>, which is just Promise<Result<T, E>>:
1declare function load(): Promise<User>;2 3load().catch(report); // ERROR: `catch` does not exist, promises do not reject4 5declare function fetchUser(id: string): AsyncResult<User, LoadError>;6 7async function rename(id: string, name: string): AsyncResult<User, LoadError> {8 const user = (await fetchUser(id))?;9 return Result.ok(user.with({ name }));10}Decorators
Decorators stay, and move to compile time: a decorator is a comptime value that may transform its target as a macro.
| Feature | Example | Ruling |
|---|---|---|
| Class and member decorators | @route("/users") class ... |
supported, evaluated at compile time |
| Runtime decorator metadata | emitDecoratorMetadata |
not supported, use reflection |
Comptime
Runtime code generation conflicts with ahead-of-time compilation, so generation moves to compile time.
| Feature | Example | Ruling |
|---|---|---|
| Dynamic code generation | runtime eval, new Function |
not supported except explicit comptime eval |
Rust
Destack's memory model is Rust-shaped with inverted defaults: values are managed unless you opt into ownership, and mutability is decoupled from exclusivity.
Ownership
Defaults
In Rust every value has exactly one owner.
In Destack the same is true, but for managed values the owner is the runtime, and ownership in the Rust sense is opt-in with ^T:
1let user = new User(); // managed: the runtime owns it2let owned: ^User = new User(); // owned: single owner, deterministic dropThe managed default is why existing TypeScript works unchanged.
Borrowing
Unlike in Rust, mutability is decoupled from borrowing: there are three borrow forms, and the aliased mutable &T sits between Rust's two.
1let mut point = Point { x: 1, y: 2 };2let a = &mut point;3let b = &mut point; // ERROR: cannot borrow `point` as mutable more than once1let point = ^Point { x: 1, y: 2 };2let a = &point;3let b = &point; // OK: mutable borrows may alias4 5a.x = 3;6b.y = 4;This is sound because a Worker is a single-threaded execution domain with explicit suspension points, so the writes cannot race.
&exclusive is &mut, and it still exists for when we do want no aliasing:
let c = &exclusive point; // ERROR: `a` and `b` are still liveInterior mutability (Cell, RefCell) exists too, but aliased &T covers most of what Rust needs it for.
Exclusivity
Exclusivity is reserved for operations that may invalidate another borrow, instead of being required for every write. Where invalidation is possible, Destack and Rust agree:
1let mut items = vec![1, 2, 3];2let first = &items[1];3items.push(4); // ERROR: cannot borrow `items` as mutable4first;1let items: Array<int32> = [1, 2, 3];2let item = &readonly items[1];3 4items.push(4); // ERROR: growth needs exclusive access while `item` is live5item;Moves
Owned values move exactly like Rust values; managed handles copy freely.
1let a: ^Buffer = Buffer.open();2let b = a;3a.length; // ERROR: `a` moved into `b`4 5let x = new User();6let y = x;7x.name; // OK: managed handles aliasDrop
Drop exists with the same meaning: owned values run their finalizer deterministically when their lifetime ends, and managed values run it when the garbage collector reclaims them.
1impl Drop for Connection {2 fn drop(&mut self) { self.close(); }3}1class Connection implements Drop {2 drop(): void {3 this.close();4 }5}Lifetimes
Inference
Lifetimes are ordinary comptime value parameters (<comptime L: Lifetime> on Borrowed<T, L>), and they are inferred everywhere, including from function bodies.
1fn first<'a>(items: &'a [String]) -> &'a str {2 &items[0]3}1function first(items: &[string]): &string {2 &items[0]3}Structs
Borrow-holding structs work like Rust's, with the lifetime as a comptime parameter:
1struct View<'a> {2 name: &'a str,3}1struct View<comptime L: Lifetime> {2 name: Borrowed<string, L>;3}Declarations
The one place lifetimes are spelled out is declare signatures, because there is no body to infer from.
They participate in regular type algebra, including unions:
1declare function choose<comptime L1: Lifetime, comptime L2: Lifetime>(2 first: Borrowed<string, L1>,3 second: Borrowed<string, L2>,4): Borrowed<string, L1 | L2>;Traits
Interfaces
Traits map to newtype interfaces.
The standard Iterator is deliberately the same shape:
1trait Iterator {2 type Item;3 fn next(&mut self) -> Option<Self::Item>;4}1newtype interface Iterator {2 type Item;3 type Return = void;4 next(): IteratorResult<this.Item, this.Return>;5}Implementations
There is no orphan rule: any module may implement any interface for any type, because Destack compiles whole programs and checks uniqueness globally.
1// ERROR: only traits defined in the current crate can be implemented (E0117)2impl Display for Vec<u8> { ... }1// fine: `implements` is globally unique per (type, interface) pair2extension of Array<uint8> implements Show {3 show(): string {4 `${this.length} bytes`5 }6}Conflicts between packages are whole-program compile errors, and libraries should only implement pairs they own one side of (a default-warn diagnostic nudges accordingly).
Blankets
Blanket implementations are supported with one familiar restriction: an implements blanket over a bare bounded parameter may only come from the package that declares the interface.
impl<T: Display> Pretty for T { ... } // only valid in the crate that owns Prettyextension<T: Show> of T implements Pretty { ... } // same rule, same reasonBlankets over nominal applications (extension<T> of Box<T> implements Show) are open to anyone, which Rust's orphan rule forbids.
Associated Types
Associated types and constants work as in Rust, with positional refinement as sugar:
fn sum(values: impl Iterator<Item = u8>) -> u32 { ... }function sum(values: Iterator<uint8>): uint32 { ... } // sugar for Iterator<type Item = uint8>Data
Enums
Rust enums map to newtype unions over object variants, with @derive(Tagged) supplying constructors and discriminants:
1enum Shape {2 Circle { radius: f64 },3 Square { side: f64 },4}1@derive(Tagged)2newtype Shape =3 | { kind: "circle"; radius: float64 }4 | { kind: "square"; side: float64 };5 6match (shape) {7 Shape.Circle({ radius }) => radius * radius * 3.148 Shape.Square({ side }) => side * side9}Optionality
1const some: int32 | null = 1;2const none: int32 | null = null;3 4type Present = NonNullable<int32 | null | undefined>;Destack does not mirror Rust's Option<T> as a standard carrier.
Ordinary absence uses TypeScript-style null / undefined unions directly, while APIs that need to distinguish completion from a yielded nullish value use explicit tagged results like IteratorResult<Y, R>.
Errors
Results
Result<T, E> and ? carry over directly, and try / catch is sugar over the same control flow:
let user = load_user(id)?;const user = loadUser(id)?;Panics
Panics take string messages only, and there is no catch_unwind: the Worker is the fault boundary, and the supervising parent observes the exit.
let result = std::panic::catch_unwind(|| risky()); // recoverable in-processpanic("invariant broken"); // unwinds the Worker; the parent sees WorkerExit "panicked"Concurrency
Send and Sync
Send and Sync exist with the same meaning and the same structural derivation, applied per memory form:
| Form | Crosses Workers when |
|---|---|
local managed T |
never |
^T |
T: Send |
&readonly T |
T: Sync, owned or static source |
&exclusive T |
T: Send, owned or static source |
&T |
never |
The &T row is the cost of aliased mutability: it is only sound within one Worker, so it never crosses.
Tasks
Where tokio::spawn hands back a detachable JoinHandle, spawned work here is owned by a scope that cannot exit while children are pending:
let handle = tokio::spawn(async { fetch().await }); // leaks if never awaited1await using scope = TaskScope.open();2const task = scope.spawn(() => fetch()); // owned: cancelled or joined before the scope exitsWorkers
Threads map to Workers: isolated heaps, explicit shared memory, and message passing.
Borrows of owned data may cross into scoped child tasks (subject to Send), which gives fork-join parallelism over borrowed data without Arc ceremony.
Generics
Monomorphization
Both languages monomorphize, and Destack additionally induces generics implicitly wherever a constraint appears in a parameter, so impl Trait maps to nothing at all:
1fn draw(shape: impl Shape) { ... } // argument position: universal2fn make() -> impl Shape { ... } // return position: existential1function draw(shape: Shape): void {} // same universal, no keyword2function make(): Shape { ... } // same existential, no keywordConstants
Const generics map to comptime parameters, and value constraints are spelled as interval bounds or checked with comptime assert at instantiation.
struct Buffer<const N: usize> { data: [u8; N] }1struct Buffer<comptime N: 0..=4096> {2 data: [uint8; N];3}Erasure
dyn Trait maps to Dynamic<T>, with DynamicSafe mirroring object safety:
let shapes: Vec<Box<dyn Shape>> = vec![circle, square];let shapes: Dynamic<Shape>[] = [circle, square];Variance
Rust infers variance and never surfaces it, while Destack computes it the same way but lets declarations pin it with in / out, checked against usage like TypeScript.
Unsafe
@unsafe plays the same role as unsafe, with the same culture: raw pointers are safe to create and carry, and only dereferencing them needs the marker.
1let pointer = &user as *const User;2unsafe { (*pointer).name.clone() }1let pointer: *User = &user;2 3@unsafe4function read(pointer: *User): string {5 pointer.name.clone()6}Comptime
Macros
Rust splits metaprogramming across macro_rules!, proc macros, and derive macros, each with its own toolchain.
Destack has one mechanism: a decorator is a static term, and one that implements Macro may transform its target as a typed declaration using the well known eval function.
1#[derive(Serialize)]2struct User { name: String }1@derive(Serialize)2struct User {3 name: string;4}Generated code comes from comptime eval over reflected declarations, so macros compose with ordinary functions and expansion runs to a fixed point.
In this example the derive can usually even be emitted since Destack additionally supports auto-derive for builtin derive macros.
Const Functions
const fn maps to comptime evaluation of ordinary functions: anything the compiler can run is fair game, with a budget instead of a stability whitelist.
const SIZE: usize = compute_size(); // compute_size must be a const fnconst SIZE: usize = comptime computeSize(); // any function, evaluated during compilationFlow
Flow tried sound-by-default inside TypeScript-shaped syntax first, and Destack lands on many of the same answers.
Objects
Exactness
Flow made object types exact by default; Destack gets the same rejections through freshness and excess property checks:
1function send(user: { name: string }) {}2 3send({ name: "Ada", admin: true }); // ERROR: `admin` is missing in object type1function send(user: { name: string }): void {}2 3send({ name: "Ada", admin: true }); // ERROR: excess property `admin`Nominality
Classes
Both Flow and Destack make classes nominal, against TypeScript's structural treatment:
1class Left {2 value: int32 = 0;3}4 5class Right {6 value: int32 = 0;7}8 9const value: Left = new Right(); // ERROR in Flow and Destack, OK in TypeScriptOpaque Types
Flow's opaque type is the closest thing in the TS family to newtype, with one difference: Flow's opacity ends at the module boundary, while a newtype is nominal everywhere and constructed explicitly.
export opaque type ID = string; // transparent inside this file, opaque outsideexport newtype Id = string; // nominal everywhere, constructed as Id("...")Variance
Sigils
Flow annotates variance per property with + / -; Destack computes it from the member surface, and in / out exist only as checked assertions:
type Source<+T> = { +value: T };1interface Source<T> {2 readonly value: T; // computes covariant on its own3}Predicates
Guards
Flow's %checks functions assert refinements; Destack's is expressions are guards the compiler understands directly:
1function isString(value: mixed): boolean %checks {2 return typeof value === "string";3}1if (value is string) {2 value satisfies string;3}AssemblyScript
AssemblyScript is the closest cousin: TypeScript syntax compiled ahead-of-time with explicit numeric types. Its gaps show what a TS-shaped native language cannot afford to give up.
Numbers
Naming
The numeric types correspond one-to-one, spelled out so they read as TypeScript:
function clamp(value: i32, low: i32, high: i32): i32 { ... }function clamp(value: int32, low: int32, high: int32): int32 { ... }number stays an alias for float64, so unannotated TypeScript keeps its meaning.
Overflow
AssemblyScript wraps silently, matching WASM; Destack traps on overflow in every build mode, and wrapping is explicit:
1let big: i32 = 2147483647;2big + 1; // -2147483648, silently1let big: int32 = 2147483647;2big + 1; // traps: overflow3big.wrappingAdd(1); // -2147483648, explicitly4Wrapping(big) + 1; // or wrap by typeExpressiveness
Closures
Closures over mutable locals - the everyday TypeScript AssemblyScript cannot compile - work natively:
1function counter(): () => int32 {2 let count = 0;3 4 return () => {5 count += 1;6 count7 };8}Unions
Union types, the other major gap, are first-class and reified:
1function describe(value: int32 | string): string {2 value is string ? value : `#${value}`3}Memory
Values
@unmanaged classes map to struct: value types are a declaration form, not an annotation on classes.
@unmanaged class Vec2 { x: f32; y: f32; }1struct Vec2 {2 x: float32;3 y: float32;4}Equality
AssemblyScript's == / === split confused users badly enough that they changed it.
Destack keeps === only where JavaScript gives it a meaning (primitives and managed identity) and rejects it elsewhere, so the question never comes up for value types.
C#
C# is the other Hejlsberg language, and several of its core distinctions transfer almost verbatim.
Values
Structs and Classes
The struct / class split means the same thing: values copied inline versus managed references.
1struct Point { public double X, Y; }2 3class User { public string Name = ""; }1struct Point {2 x: float64;3 y: float64;4}5 6class User {7 name: string = "";8}Records
Records map to structs for value semantics or newtypes for nominal wrappers, with equality via @derive(Equal) instead of being implicit:
record Point(double X, double Y);1@derive(Equal)2struct Point {3 x: float64;4 y: float64;5}(Though again it should be noted that Destack suports auto-deriving for builtin derives, so equality would be derived automatically here.)
Properties
C# properties map to accessors directly:
1class Counter {2 private int value;3 public int Count {4 get => value;5 set => this.value = value;6 }7}1class Counter {2 private value: int32 = 0;3 4 get count(): int32 {5 this.value6 }7 8 set count(next: int32) {9 this.value = next;10 }11}Generics
Variance
The in / out spelling is C#'s, with one difference: variance is computed from usage, and the annotations are checked assertions instead of requirements.
interface ISource<out T> { T Take(); }1interface Source<out T> {2 take(): T;3}Constraints
where clauses are nearly character-identical:
T Largest<T>(List<T> values) where T : IComparable<T> { ... }function largest<T>(values: T[]): T where T: Comparable<T> { ... }Errors
Exceptions
C# keeps exceptions; Destack moves failure into values like Rust:
1try {2 var user = await LoadUser(id);3} catch (LoadException error) {4 Report(error);5}1const result = await loadUser(id);2 3match (result) {4 Ok { value } => use(value)5 Err { error } => report(error)6}Nullability
C#'s nullable reference types map to plain nullable unions, checked the same way:
1string? name = FindName(id);2if (name is not null) { Use(name); }1const name: string | null = findName(id);2 3if (name !== null) {4 use(name);5}Concurrency
Async
async / await carries over unchanged, with one naming collision to know about: C#'s Task<T> is Destack's Promise<T> (the awaitable value), while Destack's Task<T> is a scope-owned work item closer to a structured Task.Run.
async Task<User> LoadUser(string id) { ... }async function loadUser(id: string): Promise<User> { ... }