
A Conversation with an LLM AI on Functional Programming
How an LLM helped me improve a C# method with functional programming techniques.
Conjunction Junction, what's your function? Hookin' up words and phrases and clauses.
Schoolhouse Rock!, written by Bob Dorough, performed by Jack Sheldon
I've been a fan of functional programming for a while now, and try to work it into as many tasks as I can. While there are a lot of good published texts on it, and a few blog posts that introduce the concepts, it can hard to see how to use the concepts in your day-to-day work.
I recently had a long, procedural method that I thought was a good candidate for improvement by functional programming techniques. I just couldn't see how to connect the dots between the materials I had read and the problem I was working on. I turned to an LLM AI for help. This is a (rough) recounting of how the conversation and the work went.
I discuss the functional programming a lot, but I hope this also illustrates one way AI can be helpful to a developer: as a programming partner you can use to develop ideas and expertise in a new area where you have some experience but want to learn more.
Note: This conversation has been edited for additional clarity and context. This is not an explicit transcription of the conversation, and these are not the actual prompts I gave or responses I received. Still, this is more or less how it went.
John: Hey, AI, I have this ugly procedural method. I accept a dto as a request, query a service to get a template, use information in that template to query for a template revision, and use information from the revision to query for a file. At the end of the method I return parts of the template, revision, and file to the caller. Fortunately, I have a complete set of unit tests for this method. It has a lot of explicit error checking that I think can be abstracted away. Our team has a rudimentary functional library, but I think we can do better. Here is the method:
1 public async Task<IResult<Core.Dto.StandardTemplateResponse>> DetermineTemplateForDocumentRequest(
2 DocumentDto request,
3 CancellationToken cancellationToken = default)
4 {
5 var theTemplateResult = await templateService.QueryTemplates(request, cancellationToken);
6 if (theTemplateResult.IsFailure)
7 {
8 return Result<Core.Dto.StandardTemplateResponse>.Failure(theTemplateResult.GetErrorsByKind(ErrorKind.NotFound));
9 }
10 var template = theTemplateResult.Value;
11
12 var currentRevisionResult = await templateRevisionService.GetCurrentRevisionForTemplate(template!.TemplateSlug!, request, cancellationToken);
13 if (currentRevisionResult.IsFailure)
14 {
15 return Result<Core.Dto.StandardTemplateResponse>.Failure(currentRevisionResult.GetErrorsByKind(ErrorKind.NotFound));
16 }
17 var currentRevision = currentRevisionResult.Value;
18
19
20 var fileRecordResult = await templateFileService.GetTemplateFileAsync(currentRevision!.Key!, request.Locale.ToString(), true, cancellationToken);
21 if (fileRecordResult.IsFailure)
22 {
23 return Result<Core.Dto.StandardTemplateResponse>.Failure(fileRecordResult.GetErrorsByKind(ErrorKind.NotFound));
24 }
25
26 return Result<Core.Dto.StandardTemplateResponse>.Success(new()
27 {
28 TemplateSlug = template!.TemplateSlug!,
29 TemplateRevisionKey = currentRevision!.Key!,
30 FileContent = fileRecordResult.Value!.Content,
31 });
32 }
Here are the functional methods Bind and BindAsync in our library:
1 public static IResult<U> Bind<T, U>(this IResult<T> result, Func<T?, U> selector)
2 {
3 if (result.IsFailure)
4 {
5 return new Result<U>(result.Status, errors: [.. result.Errors]);
6 }
7
8 try
9 {
10 var boundValue = selector(result.Value);
11 return new Result<U>(result.Status, boundValue);
12 }
13 catch (Exception ex)
14 {
15 return new Result<U>(ResultStatus.Failure, errors: Error.Internal(ex.Message, ex.StackTrace));
16 }
17 }
18
19 public static async Task<IResult<U>> BindAsync<T, U>(
20 this Task<IResult<T>> result,
21 Func<T, Task<IResult<U>>> bind)
22 {
23 var awaitedResult = await result;
24 return awaitedResult.IsFailure
25 ? Result<U>.Failure(awaitedResult.Errors)
26 : await bind(awaitedResult.Value!);
27 }
How can I use these to rewrite the original method? I don't know how to accumulate results from each step of the pipeline to return the complete result at the end. Do I need to take a reducer approach? What is the functional way to approach this?
AI: Great question — this is exactly the point where people feel the friction when moving from “procedural async + early returns” to a more functional style. You’re not missing something obvious; you’re running into a real conceptual shift.
You're not looking for a reducer: that's for when you have a collection of results that you need to boil down to a single result. What you want is a context: a single result that you enrich as you progress down the pipeline. For example, something like this:
1internal sealed record DocumentGenerationContext(
2 DocumentDto Request,
3 Template? Template = null,
4 TemplateRevision? Revision = null,
5 TemplateFile? File = null,
6);
Then you can rewrite your pipeline using BindAsync to make each call and enrich the context at each step:
1public Task<IResult<Core.Dto.StandardTemplateResponse>>
2DetermineTemplateForDocumentRequest(
3 DocumentDto request,
4 CancellationToken cancellationToken = default)
5{
6 return Result<DocumentGenerationContext>
7 .Success(new DocumentGenerationContext(request))
8
9 // 1️⃣ Query template
10 .BindAsync(ctx =>
11 templateService
12 .QueryTemplates(ctx.Request, cancellationToken)
13 .Bind(template =>
14 ctx with { Template = template }
15 )
16 )
17
18 // 2️⃣ Get current revision
19 .BindAsync(ctx =>
20 templateRevisionService
21 .GetCurrentRevisionForTemplate(
22 ctx.Template!.TemplateSlug!,
23 ctx.Request,
24 cancellationToken)
25 .Bind(revision =>
26 ctx with { Revision = revision }
27 )
28 )
29
30 // 3️⃣ Get template file
31 .BindAsync(ctx =>
32 templateFileService
33 .GetTemplateFileAsync(
34 ctx.Revision!.Key!,
35 ctx.Request.Locale.ToString(),
36 true,
37 cancellationToken)
38 .Bind(file =>
39 ctx with { File = file }
40 )
41 )
42
43 // 4️⃣ Project final result
44 .Bind(ctx =>
45 new Core.Dto.StandardTemplateResponse
46 {
47 TemplateSlug = ctx.Template!.TemplateSlug!,
48 TemplateRevisionKey = ctx.Revision!.Key!,
49 FileContent = ctx.File!.Content,
50 }
51 );
52}
John: Cool. I made a few minor changes and wound up with this method that works and passes my tests.
1public async Task<IResult<Core.Dto.QualifiedTemplateRequest>> DetermineTemplateForDocumentRequest(
2 DocumentDto request,
3 CancellationToken cancellationToken = default
4) =>
5 await
6 Task.FromResult(Result<DocumentGenerationContext>.Success(new DocumentGenerationContext(request)))
7 .BindAsync(async ctx =>
8 (await templateService.QueryTemplates(ctx.Request, cancellationToken))
9 .Bind(template => ctx with { Template = template })
10 )
11 .BindAsync(async ctx =>
12 (await templateRevisionService.GetCurrentRevisionForTemplate(ctx.Template!.TemplateSlug!, ctx.Request, cancellationToken))
13 .Bind(revision => ctx with { Revision = revision })
14 )
15 .BindAsync(async ctx =>
16 (await templateFileService.GetTemplateFileAsync(ctx.Revision!.Key!, ctx.Request.Locale.ToString(), true, cancellationToken))
17 .Bind(file => ctx with { File = file })
18 )
19 .BindAsync(ctx =>
20 Task.FromResult(Result<QualifiedTemplateRequest>.Success(new QualifiedTemplateRequest
21 {
22 SelectionCriteria = request.SelectionCriteria,
23 RequestDate = request.RequestDate,
24 Locale = request.Locale,
25 RequestData = request.RequestData,
26 TemplateSlug = ctx.Template!.TemplateSlug!,
27 TemplateRevisionKey = ctx.Revision!.Key!,
28 TemplateFileLocation = ctx.File!.Name,
29 })))
30 ;
I await and Bind inside each of the BindAsync methods; is there a way to avoid that?
AI: This is a great place to end up — you’ve got something working, and now you’re feeling the friction points that mean your abstractions are just slightly off. That’s exactly where good functional programming APIs come from.
To answer your question: yes, and you should. You're currently collapsing and re-lifting the async boundary repeatedly. Here is what your method looks like after cleaning this up:
1public Task<IResult<QualifiedTemplateRequest>>
2DetermineTemplateForDocumentRequest(
3 DocumentDto request,
4 CancellationToken cancellationToken = default) =>
5 Task.FromResult(
6 Result.Success(
7 new DocumentGenerationContext(request)))
8 .BindAsync(ctx =>
9 templateService
10 .QueryTemplates(ctx.Request, cancellationToken)
11 .Bind(template => ctx with { Template = template })
12 )
13 .BindAsync(ctx =>
14 templateRevisionService
15 .GetCurrentRevisionForTemplate(
16 ctx.Template!.TemplateSlug!,
17 ctx.Request,
18 cancellationToken)
19 .Bind(revision => ctx with { Revision = revision })
20 )
21 .BindAsync(ctx =>
22 templateFileService
23 .GetTemplateFileAsync(
24 ctx.Revision!.Key!,
25 ctx.Request.Locale.ToString(),
26 true,
27 cancellationToken)
28 .Bind(file => ctx with { File = file })
29 )
30 .Bind(ctx => new QualifiedTemplateRequest
31 {
32 SelectionCriteria = request.SelectionCriteria,
33 RequestDate = request.RequestDate,
34 Locale = request.Locale,
35 RequestData = request.RequestData,
36 TemplateSlug = ctx.Template!.TemplateSlug!,
37 TemplateRevisionKey = ctx.Revision!.Key!,
38 TemplateFileLocation = ctx.File!.Name,
39 });
John: That doesn't work. The compiler is telling me that the type signatures don't match, probably because in the last step I'm trying to convert an IResult to a DocumentGenerationContext. I could unwrap the result I receive by doing an explicit check to see if it is a success or failure, and handle it appropriately. But this is why I'm using the Bind and BindAsync methods, so I don't have to manually check for success or failure. How do I fix this?
AI: You're right about why you're writing Bind/BindAsync, and you've now hit the exact place where the type algebra of your helpers is fighting you. You currently have two different ideas collapsed into one method name:
- Mapping a successful value:
T -> U - Binding to another result:
T -> IResult<U>
Your Bind method is actually a Map. Your BindAsync is a Bind, but only for one specific shape. That's why the types feel slippery.
What I recommend is that you rename and enhance the methods in your functional library: Add Map, MapAsync, Bind and BindAsync like this:
1public static IResult<U> Map<T, U>(this IResult<T> result, Func<T?, U> selector)
2{
3 if (result.IsFailure)
4 {
5 return new Result<U>(result.Status, errors: [.. result.Errors]);
6 }
7
8 try
9 {
10 var boundValue = selector(result.Value);
11 return new Result<U>(result.Status, boundValue);
12 }
13 catch (Exception e)
14 {
15 return new Result<U>(ResultStatus.Failure, errors: Error.Internal(e.Message));
16 }
17}
18
19public static async Task<IResult<U>> MapAsync<T, U>(
20 this Task<IResult<T>> taskResult,
21 Func<T, U> selector)
22{
23 var result = await taskResult;
24
25 if (result.IsFailure)
26 {
27 return new Result<U>(result.Status, errors: [.. result.Errors]);
28 }
29
30 try
31 {
32 return Result<U>.Success(selector(result.Value!));
33 }
34 catch (Exception e)
35 {
36 return new Result<U>(
37 ResultStatus.Failure,
38 errors: Error.Internal(e.Message));
39 }
40}
41
42public static IResult<U> Bind<T, U>(
43 this IResult<T> result,
44 Func<T, IResult<U>> bind)
45{
46 if (result.IsFailure)
47 {
48 return new Result<U>(result.Status, errors: [.. result.Errors]);
49 }
50
51 try
52 {
53 return bind(result.Value!);
54 }
55 catch (Exception e)
56 {
57 return new Result<U>(
58 ResultStatus.Failure,
59 errors: Error.Internal(e.Message));
60 }
61}
62
63public static async Task<IResult<U>> BindAsync<T, U>(
64 this Task<IResult<T>> taskResult,
65 Func<T, Task<IResult<U>>> bind)
66{
67 var result = await taskResult;
68
69 if (result.IsFailure)
70 {
71 return new Result<U>(result.Status, errors: [.. result.Errors]);
72 }
73
74 try
75 {
76 return await bind(result.Value!);
77 }
78 catch (Exception e)
79 {
80 return new Result<U>(
81 ResultStatus.Failure,
82 errors: Error.Internal(e.Message));
83 }
84}
Aside: The AI misled me slightly here. What is happening is that
IResultandTaskare both value containers (monads in functional programming speak). SoBindis correctly named, but it is specifically for theIResultmonad.BindAsyncsimilarly handles results doubly wrapped in two monads: anIResultwrapped in aTask. It is specific only to that combination of monads. This multiplication of methods is the AI's attempt to unwrap those concepts, withMapbeing used to transform anIResultto an unwrapped value,Bindto transform anIResultto anotherIResult, and*Asyncbeing the counterpart to those but wrapped in aTask. As we'll see, C#'s type system is not sophisticated enough to do it in a fully functional programming way. Other languages like Haskell, Elixir, F#, and Clojure do it much more naturally and easily.
AI: With these methods in place, your original pipeline looks like this:
1public Task<IResult<QualifiedTemplateRequest>>
2DetermineTemplateForDocumentRequest(
3 DocumentDto request,
4 CancellationToken cancellationToken = default)
5{
6 return Result
7 .Success(new DocumentGenerationContext(request))
8
9 // 1️⃣ Template
10 .BindAsync(ctx =>
11 templateService
12 .QueryTemplates(ctx.Request, cancellationToken)
13 .Map(template =>
14 ctx with { Template = template })
15 )
16
17 // 2️⃣ Revision
18 .BindAsync(ctx =>
19 templateRevisionService
20 .GetCurrentRevisionForTemplate(
21 ctx.Template!.TemplateSlug!,
22 ctx.Request,
23 cancellationToken)
24 .Map(revision =>
25 ctx with { Revision = revision })
26 )
27
28 // 3️⃣ File
29 .BindAsync(ctx =>
30 templateFileService
31 .GetTemplateFileAsync(
32 ctx.Revision!.Key!,
33 ctx.Request.Locale.ToString(),
34 true,
35 cancellationToken)
36 .Map(file =>
37 ctx with { File = file })
38 )
39
40 // 4️⃣ Final projection
41 .Map(ctx => new QualifiedTemplateRequest
42 {
43 SelectionCriteria = request.SelectionCriteria,
44 RequestDate = request.RequestDate,
45 Locale = request.Locale,
46 RequestData = request.RequestData,
47 TemplateSlug = ctx.Template!.TemplateSlug!,
48 TemplateRevisionKey = ctx.Revision!.Key!,
49 TemplateFileLocation = ctx.File!.Name,
50 });
51}
John: I tried that, but I'm getting a type error with the very first block:
'Task<IResult<Template>>' does not contain a definition for 'Map' and the best extension method overload 'Map<Template, DocumentGenerationContext>(IResult<Template>, Func<Template?, DocumentGenerationContext>)' requires a receiver of type 'IResult<Template>'
AI: Yep - I feel your pain. The good news is nothing mystical is happening. The compiler is being 100% literal, and it's pointing straight at the real issue.
'Task<IResult<Template>>' does not contain a definition for 'Map'
That's because you only defined Map for IResult<T>, but you're calling it on Task<IResult<T>>. You're missing an overload of MapAsync that accepts a Task<IResult<T>> instead of just an IResult<T>:
1public static async Task<IResult<U>> MapAsync<T, U>(
2 this Task<IResult<T>> taskResult,
3 Func<T, Task<U>> selector) // asynchronous projection
4{
5 var result = await taskResult;
6 if (result.IsFailure)
7 return new Result<U>(result.Status, errors: [.. result.Errors]);
8
9 try
10 {
11 var value = await selector(result.Value!);
12 return Result<U>.Success(value);
13 }
14 catch (Exception e)
15 {
16 return new Result<U>(
17 ResultStatus.Failure,
18 errors: Error.Internal(e.Message));
19 }
20}
Aside: I had to look at this very carefully many times to really understand what's going on and why my pipeline wasn't working. Here are the signatures of the
MapAsyncmethod from earlier and what the AI is suggesting I need now:1public static async Task<IResult<U>> MapAsync<T, U>(this Task<IResult<T>> taskResult, Func<T, U> selector) 2public static async Task<IResult<U>> MapAsync<T, U>(this Task<IResult<T>> taskResult, Func<T, Task<U>> selector)The original method's selector will operate on a type T and return a value U. But note that the subsequent blocks are using
BindAsync, which means it needs to acceptTask<IResult<T>>. That's why the second overload is needed. Purely functional programming languages can handle this distinction, but C# can't. I'm not sure how they distinguish between multiple levels of wrapping, but somehow they do.
John: A colleague of mine started our functional library. He originally wanted me to call the Bind function Map. I asked him to change the name because of something I remembered from Functional Programming in C# by Enrico Buonanno:
If we use
C<T>to indicate some structure that contains value(s) of type T, thenBindtakes an instance of the container and a function with signature (T -> C<R>) and returns aC<R>. The signature ofBindis always in this form:Bind: (C<T>, (T -> C<R>)) -> C<R>
Is that not true of what I started out calling Bind with the container being IResult? Or did I give my colleague bad advice?
AI: No, you were exactly correct. Your original Bind matches Buonanno's definition of Bind exactly. Your trouble started because you introduced a second container: Task<IResult<T>>, and suddenly you needed Map for IResult, Bind for IResult, Map for Task<IResult> and Bind for Task<IResult>. That explosion of methods makes everything feel wrong, but the original naming was still correct.
John: I've written the second MapAsync implementation and gotten rid of the inner Task.FromResult calls. There's still one to start the pipeline. How can I get rid of it?
1public async Task<IResult<QualifiedTemplateRequest>> DetermineTemplateForDocumentRequest(
2 DocumentDto request,
3 CancellationToken cancellationToken = default
4) =>
5 await Task.FromResult(Result<DocumentGenerationContext>.Success(new DocumentGenerationContext(request)) as IResult<DocumentGenerationContext>)
6 .BindAsync(ctx =>
7 templateService
8 .QueryTemplates(ctx.Request, cancellationToken).AsTask()
9 .MapAsync(template => ctx with { Template = template })
10 )
11 .BindAsync(ctx =>
12 templateRevisionService
13 .GetCurrentRevisionForTemplate(ctx.Template!.TemplateSlug!, ctx.Request, cancellationToken).AsTask()
14 .MapAsync(revision => ctx with { Revision = revision })
15 )
16 .BindAsync(ctx =>
17 templateFileService
18 .GetTemplateFileAsync(ctx.Revision!.Key!, ctx.Request.Locale.ToString(), true, cancellationToken).AsTask()
19 .ContinueWith(task => (IResult<TemplateFile>)task.Result)
20 .MapAsync(file => ctx with { File = file })
21 )
22 .MapAsync(ctx => new QualifiedTemplateRequest
23 {
24 SelectionCriteria = request.SelectionCriteria,
25 RequestDate = request.RequestDate,
26 Locale = request.Locale,
27 RequestData = request.RequestData,
28 TemplateSlug = ctx.Template!.TemplateSlug!,
29 TemplateRevisionKey = ctx.Revision!.Key!,
30 TemplateFileLocation = ctx.File!.Name,
31 })
32;
AI: Your pipeline generally operates on the Task<IResult<T>> abstraction. That's what your combinators (MapAsync, BindAsync) operate on. But your starting value is an IResult<DocumentGenerationContext>. So something has to lift it into a Task. You can either add a Lift method to your library, write an overload of BindAsync, or accept the extra call. Here is what a Lift method would look like:
1public static Task<IResult<T>> ToTaskResult<T>(this IResult<T> result) =>
2 Task.FromResult(result);
John: I added a lift method to the library, so the code looks like this. I could probably also write a lift method for IResult too, but I'll save that for another time. Here is the final version of my code:
1public async Task<IResult<QualifiedTemplateRequest>> DetermineTemplateForDocumentRequest(
2 DocumentDto request,
3 CancellationToken cancellationToken = default) =>
4 await Result<DocumentGenerationContext>
5 .Success(new DocumentGenerationContext(request))
6 .LiftToTask()
7 .BindAsync(ctx =>
8 templateService
9 .QueryTemplates(ctx.Request, cancellationToken)
10 .MapAsync(template => ctx with { Template = template })
11 )
12 .BindAsync(ctx =>
13 templateRevisionService
14 .GetCurrentRevisionForTemplate(ctx.Template!.TemplateSlug!, ctx.Request, cancellationToken)
15 .MapAsync(revision => ctx with { Revision = revision })
16 )
17 .BindAsync(ctx =>
18 templateFileService
19 .GetTemplateFileAsync(ctx.Revision!.Key!, ctx.Request.Locale.ToString(), true, cancellationToken)
20 .MapAsync(file => ctx with { File = file })
21 )
22 .MapAsync(ctx => new QualifiedTemplateRequest
23 {
24 SelectionCriteria = request.SelectionCriteria,
25 RequestDate = request.RequestDate,
26 Locale = request.Locale,
27 RequestData = request.RequestData,
28 TemplateSlug = ctx.Template!.TemplateSlug!,
29 TemplateRevisionKey = ctx.Revision!.Key!,
30 TemplateFileLocation = ctx.File!.Name,
31 })
32 ;
This compiles and passes all my tests. How would this look in F#?
AI: Yeah — this is exactly the right moment to look at F#, because it will show you very clearly that this is not a fool’s errand. What you’re fighting in C# is syntax and naming, not the underlying model.
Below is your pipeline rewritten in idiomatic F#, with the same semantics:
- Result<'T, 'Error> for successes or failures
- Async<'T> for asynchrony
- No Task.FromResult
- No overload explosion
- No naming anxiety
In F#, your "container" is effectively Async<Result<'T, 'E>>, and the compiler already knows how to compose it.
Here are the domain types (simplified):
1type DocumentDto =
2 { SelectionCriteria : string
3 RequestDate : System.DateTime
4 Locale : string
5 RequestData : obj }
6
7type DocumentGenerationContext =
8 { Request : DocumentDto
9 Template : Template option
10 Revision : TemplateRevision option
11 File : TemplateFile option }
12
13type QualifiedTemplateRequest =
14 { SelectionCriteria : string
15 RequestDate : System.DateTime
16 Locale : string
17 RequestData : obj
18 TemplateSlug : string
19 TemplateRevisionKey : string
20 TemplateFileLocation : string }
F# already has Result.bind, but we'll lift it into Async:
1module AsyncResult =
2
3 let map f ar =
4 async {
5 let! r = ar
6 return Result.map f r
7 }
8
9 let bind f ar =
10 async {
11 let! r = ar
12 match r with
13 | Ok v -> return! f v
14 | Error e -> return Error e
15 }
16
17 let retn x =
18 async { return Ok x }
Here are the signatures of your services:
1val queryTemplates :
2 DocumentDto -> Async<Result<Template, Error>>
3
4val getCurrentRevision :
5 Template -> DocumentDto -> Async<Result<TemplateRevision, Error>>
6
7val getTemplateFile :
8 TemplateRevision -> string -> Async<Result<TemplateFile, Error>>
And finally, here is your pipeline, clean and linear:
1open AsyncResult
2
3let determineTemplateForDocumentRequest
4 (request : DocumentDto) =
5
6 retn { Request = request
7 Template = None
8 Revision = None
9 File = None }
10
11 |> bind (fun ctx ->
12 queryTemplates ctx.Request
13 |> map (fun template ->
14 { ctx with Template = Some template })
15 )
16
17 |> bind (fun ctx ->
18 getCurrentRevision ctx.Template.Value ctx.Request
19 |> map (fun revision ->
20 { ctx with Revision = Some revision })
21 )
22
23 |> bind (fun ctx ->
24 getTemplateFile ctx.Revision.Value ctx.Request.Locale
25 |> map (fun file ->
26 { ctx with File = Some file })
27 )
28
29 |> map (fun ctx ->
30 { SelectionCriteria = request.SelectionCriteria
31 RequestDate = request.RequestDate
32 Locale = request.Locale
33 RequestData = request.RequestData
34 TemplateSlug = ctx.Template.Value.TemplateSlug
35 TemplateRevisionKey = ctx.Revision.Value.Key
36 TemplateFileLocation = ctx.File.Value.Name })
So your instinct about Bind was correct, and your problems are not due to a lack of skill with functional or C# programming, but the language did not give you the power you needed. Task<T> is not a monad in C#, there are no higher-kinded types, async is baked outside the type system, and you have to use extension methods instead of getting language support.
John: Cool. Thanks for your help! Maybe I'll ask you some other day how to handle things like parallel paths, handling arrays of queries, and stuff like that, but this is enough for today.
Takeaways:
Even with all the fighting I had to do with types and rewriting the functional library, I think the functional pipeline is better than the original procedural method. I don't have to do all the manual error checking and short-circuiting, since the higher-order functions handle all that for me. I do have to worry a lot about types and pick the right function for the situation, however. What do you think?
Functional programming in C# is possible and worthwhile, but it could certainly be better, especially around asynchrony and Tasks.
Lastly, I tend to use LLM AIs as a pair partner to bounce ideas off and gain expertise in areas I have some familiarity with but don't know the details. That's one mode to use it in; what is your favorite? Did this help to illustrate some possibilities for you?