Skip to content

TikField

Daniel Frantík edited this page Oct 4, 2026 · 6 revisions

TikField

Every property of a built-in entity, except .id, is a TikField<T>: a value that also says whether the router printed the field at all. Read this page to understand what a loaded entity holds, what a save sends, and why the property types look the way they do.

🆕 New in 5.0. In 4.x entity properties were plain string?, bool?, long and enums. Moving code over is covered in Upgrading from 4.x to 5.0.

using System;
using System.Linq;
using tik4net;
using tik4net.Objects;
using tik4net.Objects.Ip.Firewall;

using ITikConnection connection = new TikConnectionSetup("192.168.88.1", "admin", "").Create(TikConnectionType.Api);

foreach (var rule in connection.LoadAll<FirewallMangle>())
{
    string? comment = rule.Comment.Value;                  // null when the router printed no comment
    bool disabled = rule.Disabled.GetValueOrDefault();     // false when absent, as on Nullable<T>
    if (rule.Action == FirewallMangle.ActionType.Jump)     // compares a value the router printed
        Console.WriteLine($"{rule.Chain} -> {rule.JumpTarget} ({comment ?? "no comment"}, disabled: {disabled})");
    if (rule.Action.State == TikFieldState.Unparsed)       // a word this tik4net version does not know
        Console.WriteLine($"unknown action '{rule.Action.RawValue}'");
}

Why a wrapper

A RouterOS row carries only the fields the router chose to print, and that choice varies:

  • by RouterOS version. A field one version has, another does not. Some fields were renamed, and enum vocabularies grow and shrink between versions.
  • by the row itself. A mangle jump rule has no passthrough. A static route prints no connect flag. Many fields are printed only once they hold a value.
  • by transport. A terminal print leaves out some fields the binary API returns, and a .proplist load asks for a subset.

A plain property has no way to say "the router did not print this", so something else had to stand in:

  • a bool read false, a number 0, an enum its first member;
  • a string read null or "";
  • a property with a declared DefaultValue read that default.

Each of those looks exactly like a real value. An interface that was up could read Running = false, and nothing on the entity said it was invented.

DefaultValue then carried three jobs at once: what a missing field read as, what an add left out when a value equalled it, and what change tracking compared against. A default that differed from the router's own, which happens between RouterOS versions, broke all three. An explicit value you assigned could be silently dropped from an add, because it matched the declared default, and the router then applied a different one.

And a word the property type could not hold, such as a new enum member in a newer RouterOS or a number in an unfamiliar format, failed the load of the whole menu: every row and every property, over one field.

TikField<T> removes the guessing. The state travels with the value, so:

  • a read never fails because of what another RouterOS version prints;
  • a read never invents a value. A field the row lacks is absent, and you pick the fallback where you use it;
  • a save sends what you assigned or changed, and nothing else;
  • a word the library cannot read is kept, not lost. A save writes it back unchanged.

The three states

State Meaning .Value
Absent the row did not carry the field, or the entity was never loaded (the default of the struct) null
Present printed and read, or assigned by you the value (may be null if you assigned null)
Unparsed printed, but T cannot hold the word: an enum member this version does not know, a number in another format throws TikUnparsedValueException; RawValue holds the router's word

T is always the nullable form: TikField<string?>, TikField<bool?>, TikField<long?>, TikField<FirewallMangle.ActionType?>, TikField<TikDuration?>. That is what lets = null compile on every property.

Reading

You want Write
the value, null when absent rule.Comment.Value (throws on an unparsed value rather than invent one)
the value, or the type's default rule.Disabled.GetValueOrDefault(): false / 0 for a value type, null for a reference type, as on Nullable<T>. Never throws
the value, or your own fallback email.Port.Value ?? 25, or ValueOrDefault(25)
to branch on having a value rule.Comment.TryGetValue(out var text)
to compare rule.Action == FirewallMangle.ActionType.Jump: true only for a present, equal value
"has no value" rule.Comment == null: absent, or an assigned null. An unparsed value is never null
the router's spelling rule.Action.ToString(): jump, not Jump; "" when absent
why there is no value rule.Action.State, and rule.Action.RawValue for an unparsed word

<, >, <=, >= compare like a nullable: false without a value. A sort puts absent first, then unparsed, then present values in order.

Flags the router prints only when set. A flag that is off is often simply left out: a static route has no connect, and a presence flag such as /routing/table fib exists only when it is on. Such a flag reads absent, not false, so test it with == true or GetValueOrDefault(); == false never matches it.

A set of words is a list, not a [Flags] enum: connection-state is a TikField<TikValueList<FirewallConnectionState>?> (see Value lists). A word the enum has no member for is an item of its own, and a [Flags] enum as a TikField's value is refused when the entity is first used.

Writing

Assignment is implicit:

var rule = connection.LoadAll<FirewallMangle>().First();
rule.Comment = "managed by my tool";
rule.Disabled = true;
rule.Comment = null;                                   // unsets the field on save

A literal for a duration or rate property needs its type named, because C# applies one user-defined conversion, not two: dhcpServer.LeaseTime = (TikDuration)"1d", queue.MaxLimit = (TikRatePair)"1M/2M".

What a save sends:

  • An add sends every property you assigned, including a value equal to the router's default. Nothing else is sent: not a "mandatory" field, not a type default. A field the router needs on add is one you assign.
  • An update of a loaded entity sends what changed since the load, as the router spells it. A loaded value you set to null is unset. An Unparsed value you did not touch is left as it is, so a word the library cannot read survives a save.
  • A singleton saved without loading it first sends only what you assigned.

DefaultValue on an entity property documents the router's default. It is never read into a property and never decides what is sent.

A value on its own: TikValue<T>

A TikField<T> says whether the router printed the field, and holds one TikValue<T>: the value, or the router's word when T cannot hold it, with its own !. You meet TikValue<T> when you write something more than a plain value. Both forms convert to the property's TikField<T>:

TikValue<string?> outside = TikValue<string?>.Not("10.0.0.0/8");    // the router's !10.0.0.0/8
Console.WriteLine(outside.IsNegated);                                // True
Console.WriteLine(outside);                                          // !10.0.0.0/8

var newer = TikValue<FirewallMangle.ActionType?>.FromWire("some-new-action");   // a word this version's enum lacks
Console.WriteLine(newer.IsWord);                                     // True; .Value would throw, .RawValue has the word

var rule = new FirewallMangle { Action = newer };                    // reads back as Unparsed, saved as written
Member Meaning
Value the value; throws TikUnparsedValueException when this is the router's word
IsWord, RawValue the router's word that T cannot hold, and the word itself
IsNegated, WithoutNegation() the !, and the value without it
Not(value), FromWire(word) a negated value; the router's own word, sent as written

Value lists: TikValueList<T>

A field that holds several values — ports, connection states, address types, DNS servers — is a TikField<TikValueList<T>?>. The list is immutable: With and Without return a new one, so assign the result back. Each item is a TikValue<T>, so an item can be a word T lacks without the rest of the list losing its type. A port list holds TikPortRange items and a number list (vlan-ids, netwatch http-codes) TikNumberRange items, each a single value (22, an int converts) or a range (new TikPortRange(1000, 2000), read and written 1000-2000).

TikField<TikValueList<TikPortRange>?> dstPort = new TikValueList<TikPortRange>(22, 8291, new TikPortRange(1000, 2000));
dstPort = dstPort.Value!.With(443);                          // assign the new list back
Console.WriteLine(dstPort);                                  // 22,8291,1000-2000,443

TikField<TikValueList<TikPortRange>?> notThese =
    TikValue<TikValueList<TikPortRange>?>.Not(new TikValueList<TikPortRange>(22, 8291));
Console.WriteLine(notThese);                                 // !22,8291 — the whole list negated

var topics = new TikValueList<string>("info", TikValue<string>.Not("debug"));
Console.WriteLine(topics);                                   // info,!debug — one member negated

var flags = TikValueList<FirewallTcpFlag>.Parse("syn,!ack");  // the router's spelling

Parse reads a list the way the router writes it — items separated by ,, a ! in front of an item negating that item — and refuses an item that is not a T; a word from another RouterOS version goes in as TikValue<T>.FromWire. A string does not convert to a list implicitly: C# applies one user-defined conversion per assignment, and the property's is the one from the list. A ! on the whole list is not part of the list, so Parse refuses !,syn; write it as TikValue<TikValueList<T>?>.Not(list).

  • Two lists are equal when they hold the same items the same number of times, in any order. The router prints some lists in an order of its own (ack,!syn,fin reads back fin,ack,!syn), and that must not look like a change. A save sends the items in the list's order, and a port list keeps it on the router.
  • Which ! a field takes is declared on the entity: Negatable for the whole list, NegatableMembers for each member. A ! the field does not take is refused before anything is sent, and so is a negated empty list.
  • tcp-flags over the API, REST and the CLI: a text set replaces only the half it names, plain or negated members. An update whose new value lacks a half the row holds is refused before sending, with the reason; name at least one member of each kind, or write over WinBox native (StructuredWrites), which writes both halves exactly.

Negated matchers

A firewall-style matcher can be negated: src-address=!10.0.0.0/8 matches every address outside that network, and connection-state=!established,related every state but those two. The ! is not part of the value. It reads as IsNegated, the value itself parses as usual, and TikValue<T>.Not(value) writes one:

var rule = connection.LoadAll<FirewallFilter>().First(r => r.SrcAddress.IsNegated);
Console.WriteLine(rule.SrcAddress.Value);                     // 10.0.0.0/8
Console.WriteLine(rule.SrcAddress);                           // !10.0.0.0/8

rule.SrcAddress = TikValue<string?>.Not("192.168.0.0/16");    // saved as src-address=!192.168.0.0/16
rule.DstPort = rule.DstPort.WithoutNegation();                // the matcher without its '!'
  • A negated value is never equal to the plain one. rule.SrcAddress == "10.0.0.0/8" is false for !10.0.0.0/8, and <, > are false for a negated number. Compare with TikValue<string?>.Not("10.0.0.0/8"), or test IsNegated and .Value.
  • The ! negates the whole value, a list included: dst-port=!22,8291 is "neither 22 nor 8291". A field whose members are negated one by one, such as tcp-flags=syn,!ack, keeps its !s in the value.
  • Only the matchers RouterOS negates read this way. They are marked Negatable in the shipped entities: the firewall filter, NAT, mangle and raw rules, bridge filter and NAT rules, hotspot walled-garden entries and web-proxy access rules. Anywhere else a leading ! is text: a comment may start with one. Writing a negated value to a property that is not negatable throws rather than drop the !.

Checking what a load met

A load does not fail on a value it could not read. Where your code must not act on such a value, a firewall action or a queue kind, say so after loading:

var rules = connection.LoadAll<FirewallMangle>().EnsureAllStrict();      // throws, naming every Unparsed field
var identity = connection.LoadSingle<tik4net.Objects.System.SystemIdentity>().EnsureStrict();
foreach (var item in identity.GetValueReport())                            // every field: state and raw word
    Console.WriteLine(item);

TikStrictness.Absent also refuses a field the row did not carry, except the ones you name as allowed to be missing; TikStrictness.UnknownListItems refuses a list holding an item its type cannot hold.

Every read, without asking

To see drift wherever it happens, not only where you check, attach a sink to the connection. It gets a TikEntityReadReport for every entity read that found something, and nothing for a clean one:

connection.SetEntityDiagnostics(report => Console.WriteLine(report));
// IpRoute (/ip/route/print, 12 rows): absent from every row: routing-table; unmapped: routing-vrf
  • UnparsedFields: values the property's type could not hold, with the router's word and how many rows had one.
  • FieldsAbsentFromEveryRow: mapped fields no row carried. A field this version does not have, or has renamed, or prints only for some rows and none of these. Fields your own .proplist did not ask for are left out.
  • UnmappedFields: fields the router sent that no property maps. A renamed field appears here under its new name, next to the old one in FieldsAbsentFromEveryRow. Empty on WinBox native, whose windows carry fields the API never prints.

A row delivered by a callback or a listen is reported on its own (IsComplete is false), so it has no "absent from every row". The sink runs on the loading thread before the load returns; an exception it throws is ignored. One sink per connection; ClearEntityDiagnostics() removes it.

Merging and comparing lists

TikListMerge compares TikField<T> fields by the form the router would be sent. "No value", absent or an assigned null, is one state there, as it is for == null.

Two field rules cover the cases where absence means something:

  • .Field(x => x.F, (expected, current) => expected.IfAbsent(current)) keeps the router's value where your expected row lacks the field, instead of unsetting it.
  • .Field(x => x.F, (expected, current) => expected.IfPrintedIn(current)) leaves a row alone where the router does not print the field for that kind of row: passthrough on a mangle jump, for example. Without it, an expected value the router never prints makes the rows differ on every run.

Pitfalls

  • No implicit conversion back to T. Foo(rule.Comment) where Foo takes a string does not compile; write rule.Comment.Value.
  • Comparisons through object never match. object.Equals(rule.Action, FirewallMangle.ActionType.Jump), CollectionAssert.AreEqual, or a plain value in a HashSet<object> compare a wrapper (a TikField or a TikValue) with a value. Compare .Value. MSTest's generic Assert.AreEqual(FirewallMangle.ActionType.Jump, rule.Action) is fine, since T is inferred as the TikField. The compiler warns about the direct forms — object.Equals, Assert.AreEqual(object, object), a plain value's Equals(object) — as TIK001, an analyzer in the tik4net package, with a fix to .Value. A collection or a FluentAssertions chain it cannot see.
  • Assert.IsNull(entity.Prop) checks nothing. It takes object, the struct is boxed and never null. Write Assert.IsTrue(entity.Prop == null).
  • Value throws on an unparsed value, GetValueOrDefault() does not. Pick the one that fits: the throwing form for a value you act on, the tolerant one for display.

JSON

On .NET 8 and later a TikField<T> serializes with System.Text.Json as its value: an enum as the router's word, TikDuration and the other value types as the router spells them. Absent serializes as null, unparsed as {"$raw":"word"}, a negated value as {"$not":value}, and all three read back the same way; a null reads back absent, so a deserialized entity never unsets a field. Newtonsoft.Json and the netstandard2.0 build have no converter: serialize .Value.

Your own entities

A custom entity may use TikField<T> properties, plain ones, or both. A plain property keeps the older rules: a missing field reads as the property's DefaultValue or its type's default, and an unknown enum word needs an [TikEnumUnknown] member. IsMandatory and UnsetOnDefault apply to plain properties only; both are refused on a TikField<T> property. Negatable = true on [TikProperty] marks a matcher RouterOS negates with a !; it needs a TikField<T> property. See Custom entities.

See also

Start here

API levels

Entities

Transports

Safety & diagnostics

Project

Clone this wiki locally