I know that you wrote "without adding things like Active Patterns", but I'll post a solution that uses them anyway. They are a perfect match for problems like this and they are a pretty standard F# feature, so there is really no reason why you would want to avoid them. Using active patterns here makes the code definitely more readable.
(If you're F# beginner than I can understand why you want to start with a simple solution - anyway, this may be a good motivation for you to learn active patterns eventually :-), they are not as difficult as they may appear for the first look)
You can define an active patter that matches if the string is formatted as an IP address (consisting of four substrings separated by "."):
let (|IPString|_|) (s:string) =
match s.Split('.') with
| [|a;b;c;d|] -> Some(a, b, c, d) // Returns 'Some' denoting a success
| _ -> None // The pattern failed (string was ill-formed)
match s with
| IPString(a, b, c, d) ->
// Matches if the active pattern 'IPString' succeeds and gives
// us the four parts of the IP address (as strings)
(parseOrParts a, parseOrParts b, parseOrParts c, parseOrParts d)
| _ -> failwith "wrong format"
This is the proper way that allows you to handle the case when the string is incorrect. You can of course define a version that never fails (and returns for example 0.0.0.0 if the string is ill-formed):
// This active pattern always succeeds, so it doesn't include the "|_|" part
// in the name. In both branches we return a tuple of four values.
let (|IPString|) (s:string) =
match s.Split('.') with
| [|a;b;c;d|] -> (a, b, c, d)
| _ -> ("0", "0", "0", "0")
let (IPString(a, b, c, d)) = str
(parseOrParts a, parseOrParts b, parseOrParts c, parseOrParts d)
I think most of the people would agree that this is more readable. Of course, if you want to write something simple just for a single purpose script, then you can just ignore the warning, but for anything larger, I'd prefer active patterns.