Skip to content

Upgrading from 4.x to 5.0

Daniel Frantík edited this page Sep 28, 2026 · 6 revisions

Upgrading from 4.x to 5.0

🔷 5.0 is not released yet. This page describes master, and it grows as the 5.0 changes land. See History for the full list.

The short version: every entity property is now a TikValue<T?>. Fix the compile errors that raises, then review the places that still compile but mean something different.

Package IDs, namespaces, class names and the connection API are all the same as in 4.0. What changed is the type of every mapped entity property: it now says whether the router printed the field. Most of the upgrade is mechanical, and the compiler finds all of it. The part it cannot find is code that compiles but changed meaning. That part matters most if you merge or diff entity lists.

Want an AI coding agent to do the upgrade? There is a companion page, Upgrading from 4.x to 5.0 with an AI agent, with a ready-to-paste prompt that walks Claude Code / Copilot / Cursor / Aider through every step on this page. Commit first, then review its diff and its list of behaviour-review findings — and run a merge in simulate mode against a real router, because a changed merge result is what no agent or compiler can see.


1. Update the package

Change the tik4net reference to 5.0 (and tik4net.testing if you use it). Nothing else in the package layout changed: the O/R mapper still ships inside tik4net. Update every leg of a conditional reference too (a CI package reference beside a local project reference, say): a leg left on 4.x no longer compiles against 5.0 code. Documentation and agent instructions in your project that prescribe 4.x idioms (x.Disabled ?? false) need the same update.

2. The entity value model

Every property of a built-in entity except .id is a TikValue<T?>: string? became TikValue<string?>, bool? became TikValue<bool?>, an enum TikValue<TheEnum?>, and so on. A TikValue<T> knows what the router sent:

State Meaning .Value
Absent the router did not print the field null
Present a value the value
Unparsed printed, but T cannot hold the word (say, an enum member this version does not know) throws TikUnparsedValueException; the word is in RawValue
var rule = connection.LoadAll<FirewallMangle>().First();

string? comment = rule.Comment.Value;                // null when the router printed no comment
bool disabled = rule.Disabled.GetValueOrDefault();   // like Nullable<T>: false when absent or unparsed
string chain = rule.Chain.Value ?? "";              // your own fallback
bool isJump = rule.Action == FirewallMangle.ActionType.Jump;   // compares a present value
bool noComment = rule.Comment == null;               // "has no value": absent, or null

rule.Comment = "managed";                            // assignment stays implicit
rule.Comment = null;                                 // clears the field on save

ToString() is the router's own spelling (jump, not Jump), and "" when absent. The full model, including TryGetValue, [Flags] values and strictness, is in Custom entities.

Two consequences reach beyond the property type:

  • A field the router did not print reads absent, not as the type's default and not as the property's DefaultValue. DefaultValue on the built-in entities documents the router's default and does nothing else.
  • An add sends exactly what you assigned. Nothing is sent because it is "mandatory" or because it is the type's default.

3. Fix the compile errors

Each error has one mechanical rewrite. Build again after each round: a lambda with a binding error hides the errors behind it, and a project whose dependency failed is not compiled at all, so a second, smaller round is normal.

Error 4.x code 5.0 code
CS9135 switch (rule.Action) { case ActionType.Jump: … }, x switch { … } switch (rule.Action.Value): throws on an unparsed value, usually right for an action; otherwise check State first
CS1503, CS0029 a property passed as or assigned to string (Foo(rule.Comment), string s = r.Name) .Value (may be null; keep the null handling the code already has)
CS1503, CS0029 a property passed as or assigned to a non-nullable long, int, bool (a 4.x non-nullable property such as torch Tx) .GetValueOrDefault(): absent reads 0/false, as in 4.x. .Value is long? and fails again
CS1503 string.Equals(r.Chain, x, …), string.IsNullOrEmpty(r.Comment) r.Chain.Value, r.Comment.Value
CS1929, CS1061, CS0411 a string member on the property: r.Comment.StartsWith(…), .Replace(…), .Trim() r.Comment.Value?.StartsWith(…) == true, or (r.Comment.Value ?? "") where 4.x relied on a non-null string
CS0019 x.Disabled ?? false, r.Comment ?? "" x.Disabled.GetValueOrDefault(), r.Comment.Value ?? "" (ValueOrDefault("") returns string?, a nullable warning in nullable-enabled code)
CS0019 list.FirstOrDefault()?.Interface ?? throw … list.FirstOrDefault()?.Interface.Value ?? throw …
CS0023 !a.Invalid (a 4.x non-nullable bool) !a.Invalid.GetValueOrDefault()
CS0029 if (addr.Disabled) addr.Disabled == true (absent and unparsed are not true)
CS0029, CS1662 .WithKey(r => r.Name) and other Func<T, string> lambdas r => r.Name.ToString()
CS0029 .Select(r => r.Name).ToArray() into string[] .Select(r => r.Name.Value)
CS0029 a literal assigned to a rate or duration: MaxLimit = "1M/2M", e.Timeout = "none" (TikRatePair)"1M/2M", (TikDuration)"none": C# applies only one user-defined conversion
CS1061 a member of the inner value: q.LimitAt.Token, x.Rate.HasValue q.LimitAt.Value?.Token, x.Rate.Value.HasValue
CS1061 rule.ConnectionState.HasFlag(F.Established) rule.ConnectionState.GetValueOrDefault().HasFlag(…)
CS0023 x.Time?.ToString() x.Time.Value?.ToString()
CS0117 SomeEnum.Unknown prop.State == TikValueState.Unparsed, and prop.RawValue for the word
CS1501 on GetValueOrDefault() x.Rx.GetValueOrDefault() in a file without using tik4net.Objects; add the using. The message names .NET's dictionary overload, the only one in scope
CS1503 on a conditional argument Foo(flag ? t.Rx : t.Tx) flag ? t.Rx.GetValueOrDefault() : t.Tx.GetValueOrDefault()

x.Disabled.GetValueOrDefault(), written for a 4.x bool?, compiles unchanged and keeps its meaning.

What compiles but changed meaning

None of these produce a compiler error. Search for each one, in production code and in tests.

Equality through object

A TikValue<T> never equals a plain T through object: object.Equals, CollectionAssert.AreEqual / AreEquivalent, a HashSet<object>, FluentAssertions' .Should().Be(…). Compare .Value. MSTest's generic Assert.AreEqual(FirewallMangle.ActionType.Jump, rule.Action) stays correct (T is inferred as the TikValue).

Assert.IsNull(entity.Prop) and Assert.IsNotNull(entity.Prop) take object: the struct is boxed and never null, so IsNull always fails and IsNotNull always passes, checking nothing. Write Assert.IsTrue(entity.Prop == null).

A flag the router prints only when it is set reads absent otherwise, and absent is not false: x.Dynamic == false matches no such row. Write !x.Dynamic.GetValueOrDefault().

Formatting

$"{rule.Action}", string.Format("{0}", rule.Action) and "x" + rule.Comment print the router's word (jump, not Jump) and "" for an absent value. That is harmless where both sides of a comparison are formatted the same way, such as a merge key. It matters where the text is stored, or compared with text made elsewhere.

Reads that relied on a default

In 4.x a field the router did not print read as the type's default or the property's DefaultValue; now it reads absent. ToolEmail.Port on a router that prints no port read 25 and is now absent. Where the default is what your code means, say so:

var email = connection.LoadSingle<ToolEmail>();
int port = email.Port.Value ?? 25;

Expected rows in a merge or a diff

This is the one that can rewrite a router. When you build entities in memory and compare them with rows loaded from the router (CreateMerge, SaveListDifferences, your own diffing), check each assigned property: does the router print that field for this kind of row?

A mangle jump, return or accept rule has no passthrough, so the router prints none. In 4.x an expected Passthrough = true compared equal, because the missing field read as its default. In 5.0 the loaded row has it absent, so the rows differ and every run updates the row. Remove such assignments, or give the field a merge rule that leaves the rows it does not apply to alone:

var expected = new List<FirewallMangle>();
var current = connection.LoadAll<FirewallMangle>();

connection.CreateMerge(expected, current)
    .WithKey(r => r.Comment.ToString())
    .Field(r => r.Chain)
    .Field(r => r.Action)
    .Field(r => r.Passthrough, (e, c) => e.IfPrintedIn(c))
    .Simulate(out int inserts, out int updates, out int deletes);

Use IfPrintedIn only for fields that do not apply to some rows; on an optional field that is merely unset it would never write the value. A milder, one-time variant needs no fix. Many fields are printed only once they hold a value, so an expected Log = false against a row where log was never set is written once, and later runs are equal. Run the merge with Simulate against unchanged data first: it should report no updates.

An expected row that leaves a field out still unsets it on the router, as in 4.x. Where the expected rows come from a source that may lack the field, keep the router's value with (e, c) => e.IfAbsent(c). See TikListMerge.

Adds and singleton saves send only what you assigned

4.x sent some fields you did not assign: a field marked IsMandatory, and on a singleton saved without being loaded, every non-nullable field (tls=no on /tool/e-mail). 5.0 sends what you assigned. An add that now fails with missing … names the field to assign.

Test doubles

A fake router that prints every field, "" for the empty ones, now loads Present(""), which is not equal to an absent or null expected value. Make the fake leave out fields with no value, as RouterOS does. tik4net.testing's TikFakeConnection.WithEntities does.

Values the library could not read

.Value throws on an unparsed value; GetValueOrDefault() treats it as "no value". Where your code must not act on a value it could not read (a firewall action, a queue kind), check after loading:

var rules = connection.LoadAll<FirewallMangle>().EnsureAllStrict();

EnsureAllStrict throws, naming the fields; GetValueReport() lists every field's state.

Your own entities

The package does not touch your [TikEntity] classes. A plain string? / bool? / enum property keeps the 4.x behaviour: a missing field reads as its DefaultValue. Converting them to TikValue<T?> is optional; it gives them the same absent / present / unparsed states as the built-in entities. When you convert one:

  • Every [TikProperty] except .id becomes TikValue<T?>, in the nullable form (TikValue<int> is refused when the entity is first used). In a project without nullable reference types write TikValue<string>.
  • Remove IsMandatory and UnsetOnDefault from those properties: both are refused on a TikValue<T> property, and what they did is now the default behaviour.
  • Delete constructor assignments of mapped properties. On a TikValue property such a value is Present: it is sent on every add, and every loaded entity, built through the same constructor, reads it for a field the router did not print. Keep the default in DefaultValue.
  • An enum used only by converted properties no longer needs its [TikEnumUnknown] member: an unknown word reads Unparsed.
  • A custom ITikTypeConverter that throws for a word it cannot hold makes that value Unparsed instead of failing the load.
  • TikValue<T> serializes with System.Text.Json on net8.0. Newtonsoft.Json and netstandard2.0 have no converter: serialize .Value.

A class may mix plain and TikValue<T?> properties. See Custom entities.

Other breaking changes

A refused or unreachable port throws SocketException

Telnet, Ssh, WinboxCli and WinboxNative throw the SocketException itself, as the binary API always did, instead of wrapping it in TikConnectionLoginException ("Cannot log in"). An SSH connect timeout is a SocketException with SocketError.TimedOut. A catch (TikConnectionLoginException) meant for "the router is not there" needs a catch (SocketException) as well. Rest keeps its IOException.

CLI completion: an inline completion yields no tokens

Where RouterOS completes a unique word, or the prefix all candidates share, in place instead of listing, CompleteCli returns nothing and CompleteCliRaw returns the completed line. 4.x returned the terminal's echo as tokens.

A CLI read the router refuses with a parse error throws TikCommandTrapException

4.x reported it as an incomplete read to retry, which could never succeed. The exception carries the router's message.

OvpnServer is a list entity

/interface/ovpn-server/server holds several named servers on current RouterOS 7. OvpnServer has an Id, and is read with LoadAll, not LoadSingle.

Two flags are read-only

QueueType.Default and BgpInstance.Default have a private setter: the router reports them and no set takes them.

DhcpServerLease.LeaseTime is a TikDuration

As on IpDhcpServer.LeaseTime. Assign (TikDuration)TimeSpan.FromHours(1) or (TikDuration)"1h", and read the length of time as lease.LeaseTime.Value?.Value.

The bridge priorities are a TikHexNumber

InterfaceBridge.Priority (was string) and BridgePort.Priority (was int) are TikValue<TikHexNumber?>. The API prints them in hex (0x8000) and the CLI before RouterOS 7.24 in decimal (32768); both read to the same number, and it is written in hex, because RouterOS 7.24 refuses a decimal port priority. Assign new TikHexNumber(0x80), and read the number as port.Priority.Value?.Value. See Entity value types.

New in 5.0, nothing to migrate

  • RoMON: reach a router through a neighbouring MikroTik. See RoMON connection.
  • Ordered lists: TikListMerge and SaveListDifferences reorder with the fewest moves and create rows in place; the list writers have async twins. See TikListMerge.
  • Renamed fields read on both RouterOS versions (TikPropertyAttribute.AlternateNames), and TikPropertyAttribute.WinboxLabel names a field for the WinBox native transport.

Still stuck?

Open an issue on GitHub with the compiler error and the line it points at.

Start here

API levels

Entities

Transports

Safety & diagnostics

Project

Clone this wiki locally