Working with collections and text
A useful collection program usually has several steps: validate incoming items, normalize their representation, apply a policy, and materialize a result. Keep those steps visible. A compact expression is helpful only when its evaluation and business meaning remain clear.
LINQ / for_each: normalize a sequence
This example accepts addresses from an external file or request. It discards blank entries, trims surrounding spaces and removes duplicates using an explicit .NET comparer:
import Global.System.Linq.*
public class ContactList
public static action Normalize(
addresses as Global.System.Collections.Generic.IEnumerable <of String>
)
returns Global.System.Collections.Generic.List <of String>
require addresses is not nothing
let unique = new Global.System.Collections.Generic.HashSet <of String>(
Global.System.StringComparer.OrdinalIgnoreCase
)
for_each address in addresses
if address is nothing then
continue
end if
if address is whitespace then
continue
end if
unique.Add(address.Trim())
end for_each
return unique.OrderBy(address: address, Global.System.StringComparer.OrdinalIgnoreCase)
.ToList()
end action
public static action Run()
let incoming = new Global.System.Collections.Generic.List <of String>()
incoming.Add(" ana@example.com ")
incoming.Add("ANA@example.com")
incoming.Add("boris@example.com")
incoming.Add(" ")
incoming.Add(nothing)
let normalized = Normalize(incoming)
for_each address in normalized
Global.System.Console.WriteLine("Send to {{address}}")
end for_each
end action
end class
Using HashSet here does not validate an email address. It implements this application's duplicate policy. Email identity rules vary, so a product that requires a different policy should supply a different comparer or canonicalization rule.
The comparer is explicit because HashSet, OrderBy and their overloads are external .NET APIs. Their contracts do not change merely because DirectSharp's own String comparison ignores case. The final ToList produces a concrete result; the caller does not repeat the import when it enumerates it later.
List / Dictionary: choose collection behavior
A List represents ordered items, a Set represents unique membership, and a Dictionary represents keyed lookup. Normal .NET collection types and generic syntax remain available:
public class StatusCatalog
public static action Run()
let labels = new Global.System.Collections.Generic.Dictionary <of String, String>(
Global.System.StringComparer.OrdinalIgnoreCase
)
labels.Add("NEW", "Waiting for processing")
labels.Add("DONE", "Completed")
let known = labels["new"]
let unknown = try_get labels["missing"]
Global.System.Console.WriteLine(known)
Global.System.Console.WriteLine(unknown ?? "Unrecognized status")
end action
end class
Normal indexed access requires an available item. A missing key is not silently returned as a successful lookup. try_get explicitly converts a failed acquisition into nothing; this is useful when absence is the desired fallback result.
That convenience deliberately loses the distinction between kinds of acquisition failure. Use ordinary try and typed catches when a missing key, an I/O failure and a malformed value must lead to different behavior.
Standard LINQ provides Skip, Take and TakeLast; the DirectSharp library also defines an inclusive positional Take(start, end). Both positions are zero-based. Take is strict: negative or reversed positions throw ArgumentOutOfRangeException immediately. A source shorter than the requested interval throws during enumeration. For items zero through six, Take(5, 10) can yield five and six before the next advance reports the shortage. Take(2, 4) selects two, three and four normally.
Selection is deferred; use ToList() when a snapshot is needed. Early consumer exit need not discover a shortage later in the source. Neither direct ?? throw nor a require/ensure condition catches a source exception automatically. The library shows explicit checks for additional application constraints.
The strict/Try helper fragments in this section require the library's child namespace:
import Global.DirectSharp.Collections
This is a file/project import, not a statement inside an action. With equally applicable LINQ extensions also in scope, qualify the static helper. The auto-imported DirectSharp root alone does not bring these methods into scope.
Use TryTake(start, end) for tolerant positions. Negative positions become zero, an oversized end selects through the last item, and an interval still reversed after adjustment returns nothing. For the same seven items, TryTake(-3, 2) selects zero through two, TryTake(5, 10) selects five and six, and TryTake(10, 5) returns nothing. Choose a fallback or the short contextual throw:
let window = items.TryTake(start, finish) ?? throw
Here items is a present sequence and start/finish are Integer inputs. An absent method-call result uses the ordinary NullReferenceException category; an explicit exception can select another category. TryTake reads once at the call, stopping at the requested end or source exhaustion, and returns the collected items or nothing if none were selected. Thus TryTake(20, 30) over seven items returns nothing. Arrays and ordinary lists can use their available Length/Count to avoid walking skipped items. Other sources need one bounded forward pass, with no counting prepass or restart. Reusing the selection does not reread the source. Coalescing handles this absence, but does not catch source-reading failures. Strict Take keeps deferred streaming.
Take / TryTake: choose strict or tolerant selection
The DirectSharp count-taking helpers use the same strict/tolerant distinction. Take(10) requires ten items, Skip(10) requires ten items to skip, and TakeLast(10) requires ten trailing items. They throw if the source cannot satisfy that request. Zero remains valid: Take(0)/TakeLast(0) are empty, and Skip(0) keeps everything. Skipping exactly all source items is also valid and empty.
Use the Try forms when available items are acceptable. They normalize negative counts to zero and return nothing if no items are selected. For seven items, TryTake(10) and TryTakeLast(10) return all seven; TrySkip(5) returns the last two; TrySkip(10), TryTake(0) and TryTakeLast(-2) return nothing.
let lastItems = items.TryTakeLast(3) ?? throw
let remainingItems = items.TrySkip(5) ?? throw
Here items is a present sequence. The Try helpers collect at the call, so reusing the result does not repeat source work. TryTake reads only the requested prefix, while TryTakeLast needs the end to establish the tail and TrySkip stores the remaining items. A large remainder requires memory; a source that never ends cannot complete TrySkip. They keep normal source failures. Available array/list counts may optimize these operations without a counting prepass.
DirectSharp TryTake/TrySkip/TryTakeLast return nullable IReadOnlyList results: a present selection supplies Count/indexing and reuses already-collected values. They also return nothing when the source itself is nothing. Strict Take/Skip/TakeLast instead throw ArgumentNullException for a missing source. Source-reading errors still propagate.
These are DirectSharp library contracts. Calls bound to ordinary .NET LINQ keep their familiar clipping behavior; the selected library symbol determines the meaning, rather than its spelling alone.
{{expression}}: interpolate text
String interpolation embeds an expression between {{ and }}:
public class ImportSummary
public static action Format(source as String, accepted as Integer, rejected as Integer)
returns String
require source is not nothing
require accepted >= 0
require rejected >= 0
return "{{source}}: accepted {{accepted}}, rejected {{rejected}}"
end action
end class
Interpolation is useful for display text. SQL, structured logging and other protocols need their own parameterization rules. Interpolating a user value into a SQL command string does not make the command safe.
Strings are Unicode data. .NET Char values are UTF-16 code units, so a loop over Char values is not automatically a loop over user-perceived characters. Use the appropriate text API when working with Unicode scalars, grapheme clusters or culture-specific display rules.
let face = Unicode.FromCodePoint(0x1F600)
let value = Unicode.CodePoint(face)
for_each scalar in Unicode.CodePoints("A😀B")
Global.System.Console.WriteLine(scalar)
end for_each
The loop prints 65, 128512 and 66: the emoji contributes one scalar even though it occupies two UTF-16 units. CodePoint requires exactly one scalar in the entire String; "AB" and e plus a combining accent fail that check. CodePoints streams Integer scalar values and rejects malformed surrogate text when enumeration reaches it, rather than silently substituting a replacement character. Each enumeration starts again; early exit does not decode the remaining suffix. Nothing is rejected at the call. Chars.FromCodeUnit is the separate operation for a single unit, including a surrogate unit. The library explains types and failures.
The construction helpers FromCodeUnit and FromCodePoint accept Long, so ordinary Integer inputs work too. They check the complete value before any narrowing. CodeUnit and CodePoint return Integer; for example, the numeric value of 😀 is 128512.
For a name going into a URL, HTML, XML or JSON, choose encoding at that destination rather than using interpolation as escaping. Encoding examples show direct .NET link/writer/serializer calls and distinguish them from the selected String helper profiles.
is integer / is decimal / is numeric: classify numeric text
Numeric String classifiers use invariant ASCII forms and allow a leading sign and surrounding whitespace. Integer-shaped text has digits only; decimal-shaped text can add a fraction; numeric-shaped text can also add a scientific exponent. A decimal point requires digits on both sides, so write 0.5 rather than .5 in classified input.
let whole = " +12 " is integer
let fractional = "-12.50" is decimal
let scientific = "1e3" is numeric
These ordinary examples classify true. Empty/absent input, grouping separators, currency signs, base prefixes, underscores, Unicode digits and NaN/infinity names are not accepted numeric forms.
The value must also fit the classifier's fixed range: Long for integer, Decimal for decimal, and finite Double for numeric. A huge digit string can therefore have integer shape but classify false because it exceeds Long. Likewise, 1e1000 classifies false as numeric because it cannot produce a finite Double. These tests return Boolean and do not throw for an out-of-range value.
Classification does not return a converted value. For another specific target's parsing capability, use is parsable_as with the requested type or an explicit .NET parsing API.
is like / is match: match text patterns
is like asks whether a value matches a pattern. Named captures can identify the interesting part of a filename:
public class ReportFiles
public static action Describe(filename as String)
returns String
require filename is not nothing
if filename is like "report-{{let period = *}}.csv" then
return "CSV report for {{period}}"
end if
return "Other file: {{filename}}"
end action
end class
The wildcard capture belongs to the successful match's scope. It does not create a global period variable. The pattern identifies a naming shape; it does not prove that period represents a valid month or that the file contains valid CSV.
Regex matching is available when a pattern needs a stricter grammar. Constant regex named groups can introduce scoped capture locals; repeating groups produce collections of captures. Dynamic regex values cannot introduce new compile-time local names. Choose a regex for a real parsing need rather than turning every condition into one.
multiline strings: control text and boundary lines
Matching quote fences make a report or message readable directly in source:
let message = """
Import complete
Review rejected rows before submitting again.
"""
The value starts with Import and ends with the period. The line breaks next to the fences provide structure and are not part of the value. The break between the content lines is the platform newline.
Add a blank content line when you want one at the front or end:
let padded = """
Import complete
Review rejected rows before submitting again.
"""
This value includes one intentional line break at each end. Additional blank lines are preserved. Indentation is preserved too; the trim modifier trims each content line independently when that is the intended presentation. Raw disables interpolation and retains the same boundary-line rule.
consume: acquire work from a consumable source
Enumeration visits items. Consumption acquires and removes items, or advances a forward-only source until it is permanently complete:
public class DispatchQueue
public static action Run()
let jobs = new Global.System.Collections.Generic.Queue <of String>()
jobs.Enqueue("customers-2026-10.csv")
jobs.Enqueue("orders-2026-10.csv")
consume filename from jobs
Global.System.Console.WriteLine("Dispatch {{filename}}")
end consume
Global.System.Console.WriteLine("Remaining: {{jobs.Count}}")
end action
end class
The Queue is empty after normal completion. This is appropriate for a work queue and inappropriate when later code still needs the original items.
The standard consumable protocol supports sources that must wait for the next item. Temporary emptiness is different from permanent completion. A bare concurrent queue cannot convey that distinction for an open-ended producer, while a ChannelReader or a completion-aware adapter can.
Channels are a useful .NET choice for asynchronous producers and consumers because they already support waiting and completion. ConcurrentQueue, ConcurrentStack and ConcurrentBag need a wrapper that also knows when producers are finished and signals arriving items. A thread-safe remove operation alone cannot establish that no more work will arrive.
For text files, an explicit line adapter produces String items. Blank lines remain real items; EOF ends consumption. For binary streams, an explicit chunk adapter produces independent Byte[] values and preserves partial reads. Keep the underlying reader/stream in the caller's normal resource scope. The adapter does not silently dispose it.
public async action PrintLines(lines as IConsumable <of String>)
consume line from lines
Global.System.Console.WriteLine(line)
end consume
end action
The caller supplies a line adapter over its .NET text reader. Acquisition can wait using the normal async contract. No feature adapter or new text-processing keyword is required. Native read failures remain exceptions; an item whose value is nothing is different from protocol completion.
A database reader is another forward-only source. Its current row is an ephemeral view, so copy the required values into an independent object before retaining or parallelizing a row. Lists and arrays do not become consumable merely because they are enumerable.
See collection rules and patterns.
Practice: text and collections
Predict strict versus Try selection timing. Explain why a read-only list is not necessarily an independent reader snapshot.
Previous: Objects and class kinds · Next: Contracts and errors