It's an older question, but it was relevant to me. I found another way for my purposes:
- To make 
TestClass known in the remote environment, it can be included in the abstract syntax tree (AST) of the script before processing this. The same is also very useful for using statements (which must be declared on top of the file only) or functions (which can be used local and in a remote script without double declaration). The Edit-RemoteScript function is used for this purpose. (The solution was inspired by this answer in another forum. This very useful tool can help exploring the AST.) 
- In order to get an object of the self-defined class as a 'living' object remotely or after it has been returned from the remote environment, it can be casted from 
Deserialized.TestClass to TestClass. The new constructor, which accepts a PSObject, serves this purpose. Alternatively, an op_Implicit or op_Explicit operator also accepting a PSObject can do the same. Inside this operator a class constructor must be invoked. Both operators seem to work identically in PowerShell. 
This sample code illustrates the functionality:
using namespace Microsoft.PowerShell.Commands
using namespace System.Collections
using namespace System.Diagnostics.CodeAnalysis
using namespace System.Management.Automation
using namespace System.Management.Automation.Language
Set-StrictMode -Version ([Version]::new(3, 0))
class TestClass {
    [string]$String1
    [string]$String2
    [string]$String3
    [void]DoWork() {
        $this.String3 = $this.String1 + $this.String2
    }
    TestClass([string]$string1, [string]$string2) {
        $this.String1 = $string1
        $this.String2 = $string2
    }
    TestClass([PSObject]$ClassObject) {
        $this.String1 = $ClassObject.String1
        $this.String2 = $ClassObject.String2
        $this.String3 = $ClassObject.String3
    }
}
<#
    .DESCRIPTION
        This function adds using statements, functions, filters and types to ScriptBlocks to be used for remote access.
    .PARAMETER ScriptBlock
        The ScriptBlock to be processed. Mandatory.
    .PARAMETER Namespace
        The list of namespaces to add. 'default' adds any namespaces listed in the root script's using statements. Alternatively or additionally, 
        any other namespaces can be added. These have to be fully qualified. The statement 'using namespace' must not be prefixed. 
        The using statements are inserted in the processed ScriptBlock at the position where it contains the #ImportedUsings comment.
        Defaut is an empty list.
    .PARAMETER Module
        The list of PowerShell modules to add. 'default' adds any module listed in the root script's using statements. Alternatively or additionally, 
        any other module can be added. The value of the argument can be a module name, a full module specification, or a path to a module file.
        When it is a path, the path can be fully qualified or relative. A relative path is resolved relative to the script that contains the using statement. 
        The modules referenced by path must be located identically in the file systems of the calling site and the remote site.
        The statement 'using namespace' must not be prefixed. 
        When it is a name or module specification, PowerShell searches the PSModulePath for the specified module.
        A module specification is a hashtable that has the following keys:
            - ModuleName - Required, specifies the module name.
            - GUID - Optional, specifies the GUID of the module.
            - It's also required to specify at least one of the three below keys.
            - ModuleVersion - Specifies a minimum acceptable version of the module.
            - MaximumVersion - Specifies the maximum acceptable version of the module.
            - RequiredVersion - Specifies an exact, required version of the module. This can't be used with the other Version keys.
        The using statements are inserted in the processed ScriptBlock at the position where it contains the #ImportedUsings comment.
        Defaut is an empty list.
    
    .PARAMETER Assembly
        The list of .NET assemblies to add. 'default' adds any assembly listed in the root script's using statements. Alternatively or additionally, 
        any other assembly can be added. The value can be a fully qualified or relative path. A relative path is resolved relative to the script that 
        contains the using statement. The assemblies referenced must be located identically in the file systems of the calling site and the remote site.
        The using statements are inserted in the processed ScriptBlock at the position where it contains the #ImportedUsings comment.
        Defaut is an empty list.
    .PARAMETER Type
        The list of names from types defined by the root script to add to the processed script.  
        The type definitions are inserted in the processed ScriptBlock at the position where it contains the #ImportedTypes comment.
        Defaut is an empty list.
    .PARAMETER Function
        The list of names from functions or filters defined by the root script to add to the processed script.  
        The function definitions are inserted in the processed ScriptBlock at the position where it contains the #ImportedFunctions comment.
        Defaut is an empty list.
    .PARAMETER SearchNestedScriptBlocks
        If this parameter is set, ScriptBlocks contained in the root script are also searched for functions, filters and types, otherwise only the root 
        script itself.
    .EXAMPLE
        In this example the namespaces used by the root script and two additional using namespace statements are added to $myScriptBlock.
        One type and two functions, defined by the root script, are also added:
        $myScriptBlock | Edit-RemoteScript `
            -Namespace 'default', 'System.Collections', 'System.Collections.Generic' `
            -Type 'MyType' `
            -Function 'ConvertTo-MyType', 'ConvertFrom-MyType'
    .NOTES
        Because the using statements must come before any other statement in a module and no uncommented statement can precede them, including parameters, 
        one cannot define any using statement in a nested ScriptBlock. Therefore, the only alternative to post-inserting the using statements into a 
        previously defined ScriptBlock, as is done in this function, is to define $myScript as a string and create the ScriptBlock using [ScriptBlock]::Create($myScript). 
        But then you lose syntax highlighting and other functionality of the IDE used.
        An alternative way of including functions, filters and types that are used in both, the root script and the remote script, in the latter is shown in 
        the links below. An alternative to post-insertion would be to redefine these functions, filters, and types in the remote script. However, the downside 
        is that changes to the code have to be kept in sync in different places, which reduces its maintainability. 
    .LINK 
        this function:
        https://stackoverflow.com/a/76695304/2883733
    
    .LINK 
        alternative for types:
        https://stackoverflow.com/a/59923349/2883733
    .LINK 
        alternative for functions:
        https://stackoverflow.com/a/71272589/2883733
#>
function Edit-RemoteScript {
    [CmdletBinding()]
    [OutputType([ScriptBlock])]
    [SuppressMessage('PSUseDeclaredVarsMoreThanAssignments', 'functionText', Justification = "Doesn't apply")]
    param(
        [Parameter(Mandatory, ValueFromPipeline)] [ScriptBlock]$ScriptBlock,
        [Parameter()] [AllowEmptyCollection()] [String[]]$Namespace = @(),
        [Parameter()] [AllowEmptyCollection()] [ModuleSpecification[]]$Module = @(),
        [Parameter()] [AllowEmptyCollection()] [String[]]$Assembly = @(),
        [Parameter()] [AllowEmptyCollection()] [String[]]$Type = @(),
        [Parameter()] [AllowEmptyCollection()] [String[]]$Function = @(),
        [Parameter()] [Switch]$SearchNestedScriptBlocks
    )
    begin {
        [Ast]$cmdletAst = $MyInvocation.MyCommand.ScriptBlock.Ast
        do {
            [Ast]$tempAst = $cmdletAst.Parent
        } while ($null -ne $tempAst -and ($cmdletAst = $tempAst))
        [String[]]$remoteUsings = @()
        [String[]]$remoteTypes = @()
        [String[]]$remoteFunctions = @()
    } process {
        if (($Namespace -or $Module -or $Assembly) -and -not $remoteUsings) {
            if ('default' -iin $Namespace -or
                'default' -iin $Assembly -or (
                    $Module | Where-Object -Property 'Name' -EQ -Value 'default' | Select-Object -First 1
                )
            ) {
                [UsingStatementAst[]]$allUsings = @($cmdletAst.FindAll({ $args[0] -is [UsingStatementAst] }, $false))
            }
            $remoteUsings = @(
                @(
                    @{
                        Kind  = [UsingStatementKind]::Namespace
                        Names = $Namespace
                    },
                    @{
                        Kind  = [UsingStatementKind]::Module
                        Names = $Module
                    },
                    @{
                        Kind  = [UsingStatementKind]::Assembly
                        Names = $Assembly
                    }
                ) | ForEach-Object -Process { 
                    [UsingStatementKind]$kind = $_.Kind
                    $_.Names | ForEach-Object -Process {
                        if (($kind -eq [UsingStatementKind]::Module -and $_.Name -ieq 'default') -or ($kind -ne [UsingStatementKind]::Module -and $_ -ieq 'default')) {
                            @($allUsings | Where-Object -Property 'UsingStatementKind' -EQ -Value $kind | ForEach-Object -Process { $_.ToString() })
                        } else {
                            if ($kind -eq [UsingStatementKind]::Assembly) {
                                "using $( $kind.ToString().ToLowerInvariant() ) '$_'"
                            } else {
                                "using $( $kind.ToString().ToLowerInvariant() ) $_"
                            }
                        }
                    }
                }
            )
        }
        if ($Type -and -not $remoteTypes) {
            $remoteTypes = @(
                $cmdletAst.FindAll({ $args[0] -is [TypeDefinitionAst] }, $SearchNestedScriptBlocks) | 
                    Where-Object -Property 'Name' -In $Type | 
                    ForEach-Object -Process { $_.ToString() }
            )
        }
        if ($Function -and -not $remoteFunctions) {
            $remoteFunctions = @(
                if ($SearchNestedScriptBlocks) {
                    # this is slower
                    $cmdletAst.FindAll({
                            param(
                                [Parameter()] [Ast]$Ast
                            )
                            <#
                                Class methods have a FunctionDefinitionAst under them as well, but we don't want them.
                                from: https://stackoverflow.com/a/45929412/2883733
                            #>
                            $Ast -is [FunctionDefinitionAst] -and $Ast.Parent -isnot [FunctionMemberAst]
                        },
                        $true) |
                        Where-Object -FilterScript {
                            $_.Name -iin $Function
                        } |
                        ForEach-Object -Process { $_.ToString() }
                } else {
                    # this is faster
                    Get-ChildItem -Path 'Function:' |
                        Where-Object -Property 'Name' -In $Function |
                        ForEach-Object -Process {
                            if ($_.CommandType -eq [CommandTypes]::Filter) {
                                "filter $( $_.Name ) {$( $_.ScriptBlock.ToString() )}" 
                            } else {
                                "function $( $_.Name ) {$( $_.ScriptBlock.ToString() )}" 
                            }
                        }
                }
            )
        }
        [ScriptBlock]::Create($ScriptBlock.ToString(). `
                Replace('#ImportedUsings', $remoteUsings -join "`n"). `
                Replace('#ImportedTypes', $remoteTypes -join "`n"). `
                Replace('#ImportedFunctions', $remoteFunctions -join "`n"))
    } end {
    }
}
function TestFunction {
    42
}
$ComputerName = 'Server1'
[TestClass]$obj = [TestClass]::new('1', '2')
[ScriptBlock]$testScript = {
    #ImportedUsings # the imported using statements will be inserted here
    Set-StrictMode -Version ([Version]::new(3, 0))
    #ImportedTypes # the imported types will be inserted here
    #ImportedFunctions # the imported functions will be inserted here
    $obj = $args[0]
    [ArrayList]$results = @() # using statements are working remotely
    [TestClass]$castedObj = [TestClass]$obj # the type is known remotely
    [void]$results.Add('')
    [void]$results.Add('* * * remote * * *')
    [void]$results.Add((TestFunction)) # the function is known remotely
    $castedObj.DoWork() # the type has his functionality remotely
    [void]$results.Add($castedObj.String3)
    [void]$results.Add((Get-Member -InputObject $obj))
    [void]$results.Add((Get-Member -InputObject $castedObj))
    [void]$results.Add('')
    [void]$results.Add($castedObj)
    [void]$results.Add([TestClass]::new('3', '4'))
    $results
}
$testScript = $testScript | Edit-RemoteScript -Namespace 'default' -Type 'TestClass' -Function 'TestFunction'
$credentials = Get-Credential
'* * * local * * *'
TestFunction
Get-Member -InputObject $obj
$results = Invoke-Command -ComputerName $ComputerName -Credential $credentials -ArgumentList ([TestClass]$obj) -ScriptBlock $testScript
foreach ($ctr in 0..6) {
    $results[$ctr]
}
[TestClass]$resultObj = $results[7] # also returned objects can be casted back to the original type
"this is the original instance, DoWork() is already done, String3 = '$( $resultObj.String3 )'"
$resultObj = $results[8]
"this is a new instance, DoWork() isn't done yet, String3 = '$( $resultObj.String3 )'"
$resultObj.DoWork()
"... but now, String3 = '$( $resultObj.String3 )'"
Output:
* * * local * * *
42
   TypeName: TestClass
Name        MemberType Definition
----        ---------- ----------
DoWork      Method     void DoWork()
Equals      Method     bool Equals(System.Object obj)
GetHashCode Method     int GetHashCode()
GetType     Method     type GetType()
ToString    Method     string ToString()
String1     Property   string String1 {get;set;}
String2     Property   string String2 {get;set;}
String3     Property   string String3 {get;set;}
* * * remote * * *
42
12
   TypeName: Deserialized.TestClass
Name     MemberType Definition
----     ---------- ----------
GetType  Method     type GetType()
ToString Method     string ToString(), string ToString(string format, System.IFormatProvider formatProvider), string IFormattable.ToString(string format, System.IFormatProvider formatPro… 
String1  Property   System.String {get;set;}
String2  Property   System.String {get;set;}
String3  Property    {get;set;}
   TypeName: TestClass
Name        MemberType Definition
----        ---------- ----------
DoWork      Method     void DoWork()
Equals      Method     bool Equals(System.Object obj)
GetHashCode Method     int GetHashCode()
GetType     Method     type GetType()
ToString    Method     string ToString()
String1     Property   string String1 {get;set;}
String2     Property   string String2 {get;set;}
String3     Property   string String3 {get;set;}
this is the original instance, DoWork() is already done, String3 = '12'
this is a new instance, DoWork() isn't done yet, String3 = ''
... but now, String3 = '34'
In this case it is certainly a big overhead and it would actually be easier to re-define TestClass. In larger projects with complex classes, however, the procedure may worthwhile. Another advantage: there is no longer any need to synchronize functions and classes that have been declared multiple times when changes are made.
If you are working with a PSSession in which several remote calls are passed one after the other, it may even be worthwhile to have a script executed remotely first that is used exclusively for the declarations. Then a specific typed parameter type TestClass can be used instead of Object or PSObject because type TestClass is already known when the script is invoked. A casting of the parameter can be ommitted in this case:
[ScriptBlock]$TestScript = {
    param([Parameter()] [TestClass]$Obj)
    ....
    $Obj.DoWork() # the type has his functionality remotely
    [void]$results.Add($Obj.String3)
    ...
}
Edit 1: a small correction of the function code and inserted usefull links
Edit 2: suggested by @mklement0 's answer: making the function more universal; a comment-based help has also been added
Edit 3: clarification and small correction regarding casting operators