Core syntax Static typing

Typing and Constants

Klyn is statically typed from top to bottom. Type inference keeps declarations compact, but the inferred type is still a real compile-time type. This page explains the practical rules that matter when you declare variables, annotate APIs, and rely on constants.

Klyn Is Always Typed

count = 10          # inferred as Int
ratio = 0.5         # inferred as Double
name = "Klyn"       # inferred as String

count = 12          # valid
count = "twelve"    # invalid

Inference is convenient syntax, not dynamic typing. The compiler remembers the type and checks every later assignment against it.

This also fixes generic arguments and tuple arity: a variable inferred as ArrayList<Int> cannot later become an ArrayList<String>, and Tuple<Int> cannot become Tuple<Int, String>. Use distinct variables for different types.

Inference is compile-time work

An inferred variable does not carry a dynamic type tag that changes with each assignment. The compiler resolves its type once, generates code for that type, and rejects incompatible uses before execution.

Explicit Type Annotations

import klyn.collections

count as Int = 10
price as Double = 19.99
names as ArrayList<String> = ["Ada", "Grace"]

Use explicit annotations when the type is part of the design, when a public API must stay self-documenting, or when you want a specific numeric type instead of the default literal type.

In local implementation code, inference is usually the clearest choice. Public properties, method parameters, return values, collection boundaries, and persisted models should normally expose their types explicitly.

Declaration Before Use

A variable must be declared before its first use. Klyn does not create an implicit global with a neutral value merely because a declaration appears later in the file. This keeps name resolution deterministic and catches initialization-order mistakes at compile time.

limit as Int = 10
print(limit)       # valid

print(otherLimit)  # invalid: otherLimit is not declared yet
otherLimit = 20

Assignment to a Visible Variable

An assignment without as reuses the nearest visible variable. This also applies inside a function or method: assigning to an existing module-level variable modifies that variable, without a global declaration. Only a previously unknown name creates an inferred local variable. Parameters and local variables take precedence over module-level variables.

total as Int = 0

def addToTotal(amount as Int):
    total = total + amount  # updates the module variable
    doubled = amount * 2    # local to this function

addToTotal(3)
assert total == 3

Assignment does not change the variable's declared type. If a visible variable is a Double, assigning a Long to it performs the permitted numeric conversion; it does not turn the variable into a Long. Use a distinct name when you need a separate inferred local value.

Numeric Types

Type Width Range or precision Typical literal
SByte 8 bits -128 to 127 SByte(10)
Byte 8 bits 0 to 255 Byte(10)
Short 16 bits -32,768 to 32,767 Short(10)
UShort 16 bits 0 to 65,535 UShort(10)
Int 32 bits -2,147,483,648 to 2,147,483,647 10
UInt 32 bits 0 to 4,294,967,295 10u
Long 64 bits -263 to 263-1 10l
ULong 64 bits 0 to 264-1 10ul
Float16 16 bits IEEE 754 binary16 (half precision) 0.5h
Float 32 bits IEEE 754 single precision 0.5f
Double 64 bits IEEE 754 double precision 0.5

Int and Double are the default integer and floating-point types. Choose another width when the domain, binary format, native ABI, or memory footprint requires it.

The concrete primitive types Byte, SByte, Short, UShort, Int, UInt, Long, ULong, Float16, Float, Double, Boolean, Char, and CodeUnit are final: they cannot be subclassed. Their abstract numeric bases remain available for generic constraints. For example, class Counter extends Int: is rejected with SyntaxError; use a class containing an Int property when a value needs additional state.

Integer arithmetic uses a common type that covers the complete ranges of both operands, independently of their values. Mixed signed and unsigned operands use a sufficiently wide signed type: SByte + Byte produces Short, Short + UShort produces Int, and Int + UInt or Long + UInt produces Long. No signed integer type covers the complete ULong range, so mixing a signed integer with ULong requires an explicit conversion. This rule applies to +, -, *, //, %, and **. True division / produces a floating-point result; comparisons keep their exact signed/unsigned comparison rules.

assert (-5 // 2u) is Long
assert -5 // 2u == -3l
assert -5 % 3u == 1l
assert -5l // Long(2ul) == -3l  # Explicit, range-checked conversion.
count = 3ul + 1ul             # Keep unsigned arithmetic unsigned.
Negating Signed Integers

Unary - preserves the operand's type and width for SByte, Short, Int, and Long; it does not implicitly widen small integers. These types use two's complement: their negative range contains one more value than their positive range. Consequently, negating MIN_VALUE wraps at that width and produces MIN_VALUE again, without raising an exception. This behavior is defined by Klyn and is identical for a constant and a value stored in a variable.

To obtain the positive mathematical opposite, explicitly convert to a wider signed type before negating. Widening the result afterwards is too late. No wider built-in signed integer can represent the opposite of Long.MIN_VALUE.

minimum = Short.MIN_VALUE
sameWidth = -minimum          # Short: -32768, fixed-width wraparound.
wider = -Int(minimum)         # Int: 32768, conversion before negation.
Explicit narrowing, finite precision

Assignments must respect Klyn's static numeric conversion rules. Use an explicit conversion such as Float(value) or UInt(value) when the target type is not implicitly assignable; an explicit conversion still checks its range. A permitted integer-to-floating conversion is not necessarily exact: a Double cannot represent every Long value without rounding.

Numeric Literal Forms

decimal = 1_000_000
binary = 0b1010
octal = 0o755
hexadecimal = 0xFF

unsigned = 42u
wide = 42l
wideUnsigned = 42ul
halfPrecision = 3.14h
singlePrecision = 3.14f
doublePrecision = 3.14

Underscores improve readability and do not affect the value. Integer bases may use the same unsigned and width suffixes as decimal integers. Suffix letters are case-insensitive, but lowercase forms are easier to read consistently.

An unsuffixed integer literal can take the expected integer type when its value fits that type. This applies to assignments, arguments, return values, typed collection elements, and conditional expressions whose two result branches are representable literals. Out-of-range literals are rejected at compilation. This does not permit implicit narrowing of an integer variable; use an explicit conversion for that case.

value as Byte = 10
flag = false
small as Byte = 10 if flag else 20

def minimum() as Short:
    return -32768

def accept(value as SByte) as SByte:
    return value

assert accept(42) == SByte(42)
# invalid as Byte = 256     # TypeError: the literal is out of range.

NaN and Infinity

Klyn provides six special floating-point words. They are literals with fixed compile-time types, not variables and not function calls.

Word Type Meaning
nanh Float16 Half-precision quiet not-a-number value.
infh Float16 Half-precision positive infinity; write -infh for negative infinity.
nan Double Quiet not-a-number value.
nanf Float Single-precision quiet not-a-number value.
inf Double Positive infinity; write -inf for negative infinity.
inff Float Single-precision positive infinity; write -inff for negative infinity.
assert type(nan) == Double
assert type(nanf) == Float
assert type(inf) == Double
assert type(inff) == Float
assert type(0.5h) == Float16
assert type(nanh) == Float16
assert type(infh) == Float16

assert nan != nan
assert not (nan < 0.0)
assert not (nan >= 0.0)
assert inf > 1.0
assert -inff < 0.0f

# Readable IEEE identity predicates preserve the literal precision.
assert nan is nan
assert nanf is nanf
assert nanh is nanh
assert -infh is -infh
assert inf is inf
assert -inff is -inff
assert 42.0 is not nan

NaN is unordered: it is unequal to every value, including itself, and every ordered comparison involving NaN returns false. Use is nan, is nanf, or is nanh when testing its category rather than trying equality. The corresponding is inf, is -inf, is inff, is -inff, is infh, and is -infh predicates distinguish the sign and precision of infinity. Runtime division or remainder by zero still raises ZeroDivisionException; use these words when an infinite or NaN value is explicitly part of the model.

Import klyn.math and use Math.isClose() when two floating-point results should be compared with relative or absolute tolerances. Its defaults are rel_tol=1e-9 and abs_tol=0.0; negative tolerances raise ValueException. IEEE special values keep the rules above.

import klyn.math

assert Math.isClose(0.1 + 0.2, 0.3)
assert Math.isClose(0.0, 1e-12, abs_tol=1e-9)
assert not Math.isClose(nan, nan)
assert Math.isClose(inf, inf)

Half Precision

Float16 stores an IEEE 754 binary16 value in two bytes, including in arrays and NDArray<Float16>. Its largest finite value is 65504, its smallest positive normal value is 2-14, and its smallest positive subnormal is 2-24. It has 11 significant binary digits, not the precision of Float. Rounding uses nearest, ties to even. Widening to Float or Double is exact; narrowing is explicit.

value = 123.456h
assert sizeof(Float16) == 2
assert 1.00048828125h == 1h
wider as Float = value
narrower = Float16(0.1)
assert narrower == 0.1h

For FP16 model storage, optimizers and CPU/GPU execution, continue with Deep Learning.

Displaying Floating-Point Values

Float16, Float and Double use the shortest decimal digits that convert back to the same value of the original type. Display does not arbitrarily round values to six digits or widen a Float to expose extra digits. The same rules apply to toString(), String(value), printing, collection elements and interpolation without a format specifier. Explicit formatting keeps its existing precision and presentation rules.

Decimal notation is used when the decimal exponent is between -4 and 6 inclusive (magnitudes from 0.0001 up to, but not including, 10000000). Otherwise, scientific notation uses a lowercase e, an explicit exponent sign and no leading exponent zeros. The exponent is determined from the shortest decimal representation, so 0.0001f still displays as 0.0001. Integral mantissas retain .0; zero is 0.0 or -0.0. Special values display as nan, inf and -inf, with no f suffix for Float.

assert (3.0).toString() == "3.0"
assert (0.1f).toString() == "0.1"
assert (0.1 + 0.2).toString() == "0.30000000000000004"
assert (0.0001).toString() == "0.0001"
assert (0.00001).toString() == "1.0e-5"
assert (10_000_000.0).toString() == "1.0e+7"

value = 3.1415926
assert f"{value}" == "3.1415926"
assert f"{value:.2f}" == "3.14"   # Explicit formatting controls rounding.

Conversions, Equality, and Hash Keys

small as Float = Float(3.5)
count as UInt = UInt(42)

assert 1l == 1ul
assert 1l == 1.0
assert 0.0 == -0.0

An integer-to-integer cast with as keeps the low bits of the target width, interpreting its sign according to the target type. This applies immediately, even when the cast is compared or passed as an argument without storing it. In contrast, constructors such as Byte(value) check the representable range and reject an out-of-range value. Floating-to-integer conversions also reject non-finite or out-of-range values. An unknown cast target is a compile-time TypeError.

source = 256
assert (source as Byte) == Byte(0)

wide = 2147483648l
assert (wide as Int) == Int.MIN_VALUE
# Byte(source) raises ValueException: 256 is outside Byte's range.

Numeric comparisons are mathematical across primitive numeric types and preserve full integer precision. This does not make differently typed boxed values the same hash key. Maps and sets retain the primitive key type, following the strict key semantics commonly expected from a statically typed runtime.

import klyn.collections

values = HashMap<Object, String>()
values[1l] = "signed"
values[1ul] = "unsigned"

assert values.size == 2
assert values[1l] == "signed"
assert values[1ul] == "unsigned"

const and readonly

const declares a binding that cannot be assigned again. It does not imply that every initializer is evaluated by the compiler, nor that every referenced object becomes deeply immutable. readonly instead describes instance state initialized once or a property that callers can read but cannot set.

These guarantees depend on the declaration modifiers, not on the spelling of an identifier. An uppercase name is not automatically constant, and a lowercase const binding has exactly the same protection as an uppercase one.

Contract const readonly
Primary guarantee The declared binding cannot be reassigned The field is initialized once, or the property has no writable setter
Initialization Mandatory; any type-compatible expression is accepted At declaration or during instance construction
Class member storage Implicitly static Normally per instance; a static readonly property may expose a computed class value
Reference types The reference is fixed; the referenced object may remain mutable A returned reference may still designate a mutable object
Constant Bindings
const PI = 3.141592653589793
const APP_NAME as String = "Klyn"

class ApplicationInfo:
    public const BASE as Int = 40
    public const ANSWER as Int = BASE + 2

def demo():
    const RETRY_COUNT as Int = 3
    print(ApplicationInfo.ANSWER, RETRY_COUNT)

def describe(readonly value as Int) as String:
    # value = 0                 # Compile-time error: the parameter is fixed.
    return String(value)

A const declaration requires an initializer; a readonly parameter receives its value from the call and cannot be rebound inside the function (see Parameter Passing and Contracts). Class constants belong to the type itself, so ApplicationInfo.ANSWER is available without an instance. Do not write static const: static would be redundant and the compiler rejects it. Constant bindings cannot be reassigned, incremented, decremented, or modified through compound assignment.

Value and Reference Semantics
import klyn.collections
import klyn.time

class Dates:
    public const BIRTH_DAY = DateTime(1973, 8, 26)
    public const LABELS = ArrayList<String>()

assert Dates.BIRTH_DAY.year == 1973
Dates.LABELS.add("birthday")        # Allowed: the reference is constant.

# Dates.BIRTH_DAY = DateTime(2000, 1, 1)  # Compile-time error.
# Dates.LABELS = ArrayList<String>()      # Compile-time error.

For a ValueType such as Timestamp, the constant denotes the value itself. DateTime is a reference type and accepts null to represent an absent date. For a reference type, const has final-reference semantics: the reference cannot change, but the object's public mutation operations remain available. String is immutable, independently of const. MutableString supports content mutation: a const MutableString fixes its reference, not its contents.

Immutable and Mutable Text

"text" creates an immutable String. m"text" creates a MutableString. The types do not inherit from each other. Convert explicitly with String(value), value.toString(), or MutableString(value).

text = "Klyn"
alias = text
text += "!"                 # Rebinds text; alias remains "Klyn".

editable = m"Klyn"
snapshot = editable.toString()
sameObject = editable
editable[0] = 'k'
assert sameObject == "klyn"
assert snapshot == "Klyn"   # Also guaranteed for values read through reflection.

const LABEL = "Ready"       # Immutable text and a fixed reference.
const BUFFER = m"Ready"     # Fixed reference, editable contents.
BUFFER[0] = 'r'

Index and slice assignment, lower(), upper(), capitalize(), title(), trim(), ltrim(), rtrim(), and removeAccents() are available only on MutableString. Read-only transformations such as toUpper() and replace() return String snapshots. split() and lines() return lists of immutable strings.

toString() shares the immutable backing text without copying it. Editing a MutableString replaces that backing value, so snapshots and copies remain unchanged. Index and slice replacement copy text; use StringBuilder when assembling a large result through many appends. A mutable value must not be edited while it is used as a map or set key.

StringBuilder(size) and StringBuilderSync(size) reserve UTF-16 CodeUnit storage, not characters. This reservation does not change the logical length: size and length() count Unicode scalars. The constructor argument and logical lengths use ULong; scalar positions and indices use Long. The buffer grows geometrically as needed; clear() retains its capacity for reuse. A toString() result is an immutable snapshot, unaffected by later builder edits.

builder = StringBuilder(64ul)  # Reserve 64 UTF-16 code units.
assert builder.size == 0ul
builder.append('A').append('\U0001F600')
assert builder.size == 2ul     # Two Char values, stored in three CodeUnit values.
text = builder.toString()
builder.clear()
assert text == "A\U0001F600"

Combine m with f for interpolation or r for raw text: fm"Hello {name}", rm"C:\folder", and frm"{name}\n". Flags may appear in any order, once each, immediately before double quotes. Triple-double-quoted strings support the same prefixes. if remains a conditional keyword; i"..." is not a string literal.

Both types count Unicode scalars for size, indexing and slicing. Negative indices count from the end: "Message"[-7] == 'M'. Arrays returned by codePoints or codeUnits are independent copies.

Compile-Time Evaluation Is Independent
class Constants:
    public const ANSWER = 40 + 2
    public const STARTED_AT = DateTime.now

The compiler folds ANSWER because its initializer is a constant expression. STARTED_AT is nevertheless legal: it is initialized exactly once at runtime and stored as a class constant. Whether an initializer can be folded is an optimization decision and does not change the source-level meaning of const.

Read-Only Instance State and Properties
import klyn.collections

class Project:
    private readonly _identifier as Int
    public readonly property files as ArrayList<String>

    public Project(identifier as Int):
        this._identifier = identifier
        this.files = []

project = Project(7)
project.files.add("main.kn")         # Allowed: the property is shallow.
# project.files = []                 # Compile-time error: no setter.

Keep readonly for parameters, instance fields that are assigned during construction, and getter-only properties. Use const for a local binding or a class-owned value whose binding must never change.

The complementary writeonly property contract exposes a setter without a getter. It is documented with property accessor rules in Attributes and Properties.

Null References and notnull Contracts

Reference values (classes, interfaces, strings, arrays, and collections) may hold null by default, whereas primitive and ValueType values are never null. Use is null and is not null for explicit null checks, and add the notnull modifier when a reference variable or parameter must never hold null.

A ValueType declaration without an initializer must be definitely assigned before its value is read, on every path reaching that read. Instance fields and automatic properties of these types must be initialized by a field initializer or on every normal constructor exit. No constructor is implicitly called to manufacture a default value. Invalid reads fail with TypeError; casting a null value known only at runtime to a ValueType raises NullException.

Dereferencing a reference that is statically known to be null fails compilation with TypeError. This applies to instance methods, properties, fields, indexing and slicing. When nullability is unknown, the access is checked at runtime and raises NullException on a null reference. Passing null to a nullable parameter remains legal. Use is null and is not null for null guards: unlike == and !=, these identity tests never invoke a user-defined equality operator or its parameter contracts.

message as String = null

if message is null:
    print("no message")
else:
    print(message)

# Local non-null reference binding (explicit type or inferred)
notnull currentLabel as String = "ready"
notnull activeTag = "v1"
currentLabel = "running"          # OK: non-null reassignment

# currentLabel = null             # Compile-time TypeError.

def pickLabel(flag as Boolean) as String:
    return "done" if flag else null

# Checked at runtime when static analysis cannot prove non-nullability:
try:
    currentLabel = pickLabel(false)
catch err as NullException:
    print("Rejected null reassignment")

A local notnull variable must be initialized when declared and remains mutable, but every assignment to it is guarded:

  • Compile-time check: initializing or reassigning with a literal null (or an expression statically proven to be null) fails compilation with TypeError.
  • Runtime check: when an expression's nullability depends on runtime control flow, the compiler inserts a guard that raises NullException before the variable can be bound to null.
  • Reference types only: applying notnull to a primitive or ValueType (such as Int or Timestamp), or combining const with notnull, is rejected at compile time.
Non-Null Parameters
def lengthOf(notnull value as String) as ULong:
    return value.size

def joinAll(notnull parts... as String) as String:
    return ",".join(parts)

On function, method, constructor, and lambda parameters, notnull enforces the same two-level guarantee (compile-time TypeError when provably null, runtime NullException otherwise) at the call boundary and on any reassignment inside the body. On variadic parameters (notnull parts... as String), each passed element is checked individually.

Library APIs use these contracts for required file-reader/writer paths, text-search patterns and callbacks, JSON documents and destination writers, scaler data, model inputs and targets, and required NDArray shapes and tensor operands. JSON values may still be null; optional encodings, search settings, reduction axes, masks, result buffers and histogram weights retain their documented null defaults. A weighted-mean or weighted-bin-count overload, however, requires a non-null weight tensor.

notnull also combines with readonly, ref, and out parameters—for example, notnull out result as String lets the caller pass an uninitialized or null variable while guaranteeing that the function assigns a non-null reference before returning. See Non-Null Parameter Contracts in the Functions chapter for complete examples.

Function and Lambda Types

A lambda with parameters encloses them in parentheses, even with just one parameter: lambda(value as Int): expression. With no parameters, lambda: expression is equivalent to lambda(): expression. The form lambda value: expression is a syntax error. Whitespace before the opening parenthesis is optional.

square as (Int) -> Int = lambda(value as Int) as Int: value * value

power = lambda(value as Double, exponent as Double) as Double:
    return value ** exponent

assert type(square) == (Int) -> Int
assert type(power) == (Double, Double) -> Double

A function type lists its parameter types before -> and its return type after it. Klyn can infer the variable type from a typed lambda, while an explicit function annotation is useful for callbacks, fields, collection elements, and public method parameters. Structural function types are the normal callable types in statically typed application code: unlike a return-only reflective abstraction, they preserve both arity and every parameter type.

Lambda parameters may omit annotations when an expected function signature supplies their types. The return type is inferred from the body using the same rules as ordinary expressions. If the compiler cannot determine a complete static signature, compilation fails with TypeError: parameters never become dynamically typed and do not default to Int.

increment as (Int) -> Int = lambda(value): value + 1
assert increment(41) == 42

square = lambda(value as Double): value * value
assert type(square) == (Double) -> Double

answer = lambda(): 42
assert answer() == 42

# Rejected: no annotation or expected signature determines the parameter type.
# ambiguous = lambda(value): value + 1

Lambda parameters may declare trailing default values. Each omitted argument is evaluated when the lambda is called, in the lambda's captured scope; earlier parameters are available, but the current parameter and later parameters are not. Supplying an argument, including null for a reference type, does not evaluate its default expression.

scale = lambda(value as Int = 10): value * 2
assert scale() == 20
assert scale(3) == 6

scale = lambda(value as Int = 20): value * 3
assert scale() == 60

callback as (Int) -> Int = scale
assert callback(3) == 9
# callback() is rejected: (Int) -> Int requires one argument.

Optional arguments are available only when known statically for the lambda value. A structural function type records by-value parameter types and the result, not default values or ref / out passing modes: assigning or passing a lambda as (Int) -> Int keeps its full-arity by-value contract, and functions with ref or out parameters cannot be converted to a (T) -> R function value. Defaults belong to the current lambda, so reassignment does not reuse a previous lambda's defaults. A parameter type can also be inferred from a statically typed default, such as lambda(value = 10): value * 2.

def calculate(
    left as Int,
    right as Int,
    operation as (Int, Int) -> Int
) as Int:
    return operation(left, right)

combine = lambda(a as Int, b as Int) as Int: a + b
assert calculate(20, 22, combine) == 42
Use the complete signature

Use () -> Void for an action without parameters and (A, B) -> R for a callback with parameters. The klyn.reflection.Invocable<R> interface is reserved for advanced reflective calls whose arguments are discovered at runtime and packed in an Object[]. Klyn does not implicitly convert a structural function to Invocable<R>, because doing so would discard its statically checked parameter signature.

CodeUnit, Char, and Unicode

Klyn separates UTF-16 representation from Unicode text semantics. A CodeUnit is exactly one 16-bit UTF-16 code unit. A Char is exactly one 32-bit Unicode scalar value and can therefore represent a supplementary character without splitting it into a surrogate pair.

Type Literal Storage Use
CodeUnit `K`, `\u03C0` 16-bit UTF-16 code unit UTF-16 buffers, SAX APIs, native interoperability
Char 'K', '😀', '\U0001F600' 32-bit Unicode scalar String indexing, text algorithms, Unicode characters
letter as CodeUnit = `K`
letterScalar as Char = letter
smile as Char = '😀'

assert letterScalar == 'K'
assert smile.toInt() == 0x1F600
assert smile.utf16SequenceLength == 2u

# Narrowing is explicit and only valid for a Char that fits in one UTF-16 unit.
assert CodeUnit(letterScalar) == letter
A surrogate is not a Char

A supplementary character such as 😀 cannot be stored in one CodeUnit. UTF-16 represents it with a high and low surrogate, while Char stores the complete scalar. Klyn converts CodeUnit to Char implicitly, but validates the code unit: an isolated surrogate raises ValueException. Narrowing a supplementary Char to CodeUnit also raises ValueException.

Strings Are Scalar Text, Not Byte Buffers

text = "A😀πB"
bytes as Byte[] = f:[Byte(75), Byte(108), Byte(121), Byte(110), Byte(0), Byte(1)]

assert text.size == 4u
assert text[1u] == '😀'
assert text[1:2] == "😀"
assert bytes.size == 6ul

A Klyn String is an immutable object storing UTF-16 text with an explicit length. Its size, indexing, iteration, and slicing operate on Unicode scalar values; indexing therefore returns a Char. The code point U+0000 is ordinary text and may appear anywhere in a string without truncation. Use Byte[] for binary payloads, native buffers, and file or network data that has not yet been decoded as text.

sizeof(String) measures the fixed object layout, not its text length: on 64-bit platforms it is 16 bytes, comprising the type descriptor pointer and the immutable UTF-16 payload reference. The separately allocated payload holds its lengths and two bytes per code unit; no four-byte reserve per character is imposed. sizeof(text) uses the same fixed layout.

Scalar and UTF-16 views
text = m"A😀B"

characters = text.codePoints
assert characters.size == 3
assert characters[1] == '😀'

# Mutation requires MutableString and a scalar Char.
text[0] = 'B'
assert text == "B😀B"

# codeUnits exposes a snapshot of the 16-bit UTF-16 storage.
units = text.codeUnits
high as CodeUnit = units[1]
low as CodeUnit = units[2]
assert units.size == 4
assert high == `\uD83D`
assert low == `\uDE00`

codePoints returns a new Char[] snapshot and codeUnits returns a new CodeUnit[] UTF-16 snapshot. The scalar count is available through size; the UTF-16 length is the snapshot's size. Keep that snapshot in a local variable when several code units are needed because repeatedly evaluating either array property would allocate a new array each time.

Character formatting is textual by default. Width and alignment therefore count displayed scalar text rather than formatting the numeric code point.

assert f"[{'é':<3}]" == "[é  ]"
assert f"[{`é`:<3}]" == "[é  ]"
FFI and I/O boundary

Validate the external encoding before creating a String. Keep arbitrary C buffers and protocol frames as Byte[] until decoded. APIs requiring a zero-terminated C string, such as operating-system paths, reject embedded U+0000 rather than silently truncating it.

Generic Types in Declarations

import klyn.collections

names as ArrayList<String> = ["Ada", "Grace"]
config as Map<String, Int> = {"port": 8080}
values as IList<Int> = i:[1, 2, 3]
linked as List<Int> = l:[1, 2, 3]
pair as Tuple<String, Int> = ("age", 42)

Generic arguments are part of the declared type. Literals often infer them automatically, but explicit generic annotations are usually clearer in public APIs. In Klyn, IList<T> is the read-only indexed homogeneous collection contract, List<T> is the mutable indexed contract, Map<K,V> is the non-instantiable associative contract, and Tuple<...Ts> denotes a fixed positional tuple. A map literal supplies a concrete implementation assignable to that contract.

Inspecting a Type Quickly

value = [10, 20, 30]
metadata = type(value)
print(metadata.fullName)

The full reflection API is documented later, but type(...) is already useful when you want to verify what the compiler or runtime considers the real type of a value.

type(value) == Int checks the exact runtime type, whereas value is Int tests membership in that type, including inheritance and implemented interfaces. Neither test performs numeric conversions. An Object variable keeps its declared static type even when it contains an Int. A null reference is not an instance of any type.

value as Object = 42
assert type(value) == Int
assert value is Int
assert value is Object
assert value is not Double
assert 42 is not Double     # Assignment conversion does not change type identity.

Common Mistakes

  • Expecting inference to allow later assignments of unrelated types.
  • Writing redundant static const instead of a class-level const.
  • Assuming that const recursively freezes a reference type.
  • Confusing a constant binding with an expression that the compiler can evaluate at compile time.
  • Using readonly for a class-owned field instead of const.
  • Relying on implicit numeric narrowing instead of converting explicitly.
  • Testing NaN with equality instead of using its unordered comparison semantics.
  • Assuming mathematically equal values of different primitive types are the same boxed map key.
  • Leaving externally visible properties untyped because “the compiler can infer it anyway”.